diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a8e609c436a..ca322ab6951 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Nix - uses: cachix/install-nix-action@v16 + uses: cachix/install-nix-action@d56f3ce9be45c562799280e8a561fbbe8f36de44 # v16 - name: Clone wire-docs and build run: | diff --git a/.headroom.yaml b/.headroom.yaml index 32a2f4860e8..f1f0ea36cfd 100644 --- a/.headroom.yaml +++ b/.headroom.yaml @@ -1,5 +1,8 @@ version: 0.4.0.0 -run-mode: replace +# 'add' only touches files that have no header at all. Existing headers +# (and, importantly, the years in them) are left alone; see +# https://github.com/wireapp/wire-server/pull/4851. +run-mode: add source-paths: - libs - services @@ -13,7 +16,9 @@ variables: organization: Wire Swiss GmbH email: opensource@wire.com project: This file is part of the Wire Server implementation. - year: "2025" + # only ever used for files that get a header added now, so this needs no + # maintenance and causes no churn in files that already have one. + year: "{{ _current_year }}" license-headers: haskell: file-extensions: ["hs", "hsc"] diff --git a/CHANGELOG.md b/CHANGELOG.md index ea1e32e217d..093e56294e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,462 @@ +# [2026-08-27] (Chart Release 5.35.0) + +## Release notes + + +* The PostgreSQL connection pool implementation was switched to `hasql-resource-pool`. + The `agingTimeout` setting is now ignored and should be treated as deprecated. + Pool metrics now include acquisition/session latency. (#5323) + +* Background-worker now runs additional jobs and has new settings. The `jobs` settings configure the dispatcher, worker, retry, shutdown, and reaper behavior, with defaults matching the existing behavior. The initial queues are `meetings` and `conversations`, with one worker pool assigned to each queue. + + Operators should size the background-worker PostgreSQL pool and PostgreSQL `max_connections` for this workload and the transient connection used while acquiring the migration lock; that connection is closed after migrations complete. `jobs.workerThreads` defaults to `1`. + + Both worker pools use the same PostgreSQL pool. Connections are borrowed for active job transactions and short-lived worker operations, rather than being reserved permanently per pool. LISTEN/NOTIFY is disabled, so the job runner does not open an additional listener connection. + + The Helm chart sets `background-worker.terminationGracePeriodSeconds` to `40`, providing a margin over the default `jobs.gracefulShutdownTimeout` of `30s`. Adjust both settings together if changing the shutdown timeout. (#5289) + +* * The `backgroundEffects` team feature flag is **deprecated** (WPB-27912). Its + default is now **enabled and locked**, and the Helm configuration override for + `backgroundEffects` has been removed from `charts/wire-server`. The flag's + data type and its public/internal HTTP endpoints are retained for backward + compatibility; any Helm overrides for `backgroundEffects` are now ignored and + can be removed. The public/internal HTTP endpoints return 404 at API version + v17 and remain available through v16; the flag type remains deprecated. The + aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints + continue to include `backgroundEffects` at all API versions, including v17. (#5431) + +* Make SCIM error responses comply with RFC7644. Any code that processes SCIM error responses must be changed to follow the standard, instead of the previous Wire implementation. + + Previous schema (incompatible with RFC): + + ``` + { + "code": 400, + "label": "scim-error", + "message": "{\"detail\":\"[...]\",\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:Error\"],\"scimType\":\"invalidValue\",\"status\":\"400\"}" + } + ``` + + New schema (RFC-compliant): + + ``` + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status": "400" + "scimType": "invalidValue", + "detail": "[...]", + } + ``` (#5439) + +* SCIM: Make role and entitlements fields in user schema comply with RFC. Any code that processes SCIM users must be changed to follow the standard, instead of the previous Wire implementation. + + Previous User schema (incompatible with RFC): + + ``` + { + ... + "roles": ["member"], + "entitlements": ["some entitlement"], + ... + } + ``` + + New schema (RFC-compliant): + + ``` + { + ... + "roles": [{"value" : "member"}], + "entitlements": [{"value" : "some entitlement"}], + ... + } + ``` + + For backwards compatibility, both fields still accept the old bare-string form on input. (#5440) + +* Added a new galley setting `settings.meetings.pastEditPeriod` (default 24h): how far into the past `PUT /meetings/{domain}/{id}` may move a meeting's `start_time`/`end_time`, so past and ongoing meetings can be corrected after the fact. Previously any start time in the past (beyond a 60s tolerance) was rejected while a meeting was still upcoming. Galley refuses to start if `pastEditPeriod` is negative or greater than `settings.meetings.validityPeriod`. (#5451) + +* Update meeting conversations from `{private, invite}` to `{invite, code}` so meetings can be joined by code. (#5464) + +* The _prevent adminless groups_ feature has known bugs. Disable and lock it by + Helm configuration for now. Development of this feature will continue, it + should just not be used in production as-is. (#5472) + +* The `reaper` chart no longer grants itself `cluster-admin` and no longer uses an + unmaintained container image. Upgrading is a drop-in `helm upgrade`; no manual steps. + + Two cases need action: + + * If you override `image` in your values, update the override: the default changed from + `docker.io/bitnamilegacy/kubectl:1.32.4` to `docker.io/alpine/kubectl:1.36.3`. The + image must contain a POSIX shell at `/bin/sh` — distroless kubectl images do not work. + * If you mirror images into a private registry (airgapped installs), add the new image. + + See `charts/reaper/README.md` for the image settings, the RBAC the chart now creates, + and the rest of the changes. (#5444) + +* * The `meetingsPremium` team feature flag is **deprecated** (WPB-26771). It no + longer affects meeting behaviour: team meetings are always non-trial + regardless of its value. Its default is now **enabled and locked**, and the + Helm configuration override for `meetingsPremium` has been removed from + `charts/wire-server`. The flag's data type and its public/internal HTTP + endpoints are retained for backward compatibility but have no behavioural + effect; any Helm overrides for `meetingsPremium` are now ignored and can be + removed. The public/internal HTTP endpoints now return 404 at API version v17 + and remain available through v16; the flag type remains deprecated. The aggregate `GET /feature-configs` and `GET /teams/:tid/features` endpoints continue to include `meetingsPremium` at all API versions, including v17. (#5326) + +* * Galley has a new optional `settings.meetings.email` configuration block + (WPB-27175) for sending meeting-invitation emails to invited external + addresses. It takes a required `from` sender, an optional `replyTo` address, + and a `transport` that selects AWS SES or SMTP (the same shape Brig uses). + When the block is unset, meeting invitation emails are disabled. For SMTP, + set `galley.secrets.smtpPassword` (mounted at + `/etc/wire/galley/secrets/smtp-password.txt`) and point + `settings.meetings.email.smtp.passwordFile` at that path; the Galley ConfigMap + injects it into `transport.smtpCredentials.smtpPassword`, the same pattern + Brig uses for `smtp.passwordFile`. This change adds the + configuration plumbing only; email sending itself lands in a follow-up. (#5346) + +* Starting at API version V17, the `Meeting` type returned and accepted by the + meetings endpoints carries `tzid` (IANA time zone) and drops the deprecated + `trial` field; `end_time` is retained on both V17 and V16. The operator config + `galley.config.settings.meetings.legacyTimeZone` (default `Europe/Berlin`) now + applies only to meetings created by legacy clients (API < V17); reads no longer + need it, as `tzid` is persisted `NOT NULL` (backfilled to `Europe/Berlin`). (#5391) + + +## API changes + + +* SCIM user resources returned by spar now include `type` on stored email + addresses. Previously spar persisted no `type`, so SCIM PATCH operations with a + value-path filter like `emails[type eq "work"].value` (as sent by Microsoft + Entra ID) never matched the stored entry and silently appended a duplicate + email instead of updating the address in place. Clients that compare full SCIM + user payloads rather than individual fields (e.g. strict equality on the + `emails` array) will see the additional `type` member. (WPB-23434) (#5419) + +* Introduced meeting-specific conversation lifecycle events: `conversation.create-meeting` and `conversation.delete-meeting`. When a conversation of type meeting (`group_conv_type: "meeting"`) is created or deleted, clients receive these instead of `conversation.create` / `conversation.delete`. The payloads are identical to their non-meeting counterparts (`conversation.delete-meeting`, like `conversation.delete`, carries no `data`); only the event `type` differs, so clients can handle meetings distinctly. (WPB-26626) (#5421) + +* `POST /meetings` (create) and `PUT /meetings/{domain}/{id}` (update) now return a full `conversation` object alongside the existing meeting fields. The legacy `qualified_conversation` field is retained for backward compatibility. (#5301) + +* Roll back: do *not* include collaborator apps in get-apps end-point. (#5402) + +* The `backgroundEffects` team feature endpoints are deprecated and return 404 for + clients on API version v17: the public `GET`/`PUT /teams/:tid/features/backgroundEffects` + and the internal legacy lock `PUT /i/teams/:tid/features/backgroundEffects/(un)?locked`. + They remain available through v16. The aggregate endpoints + `GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue + to include `backgroundEffects` at all API versions: the aggregate feature list is + version-agnostic, like other version-gated features such as MLS. (#5431) + +* Make scim error responses comply with RFC7644. (#5439) + +* SCIM: advertise all schemas used in User (fixes RFC compliance issue). (#5441) + +* SCIM: Make role and entitlements fields in user schema comply with RFC. (#5440) + +* The `mlsMigration` team feature config now includes an `allowManualMigration` + boolean field (default `false`) that controls whether clients are permitted to + perform single-group (manual) MLS migrations. The field only steers client + behaviour (e.g. if a migration button is shown or not). It does not enforce + checks in the backend. (#5456) + +* New oauth scopes for meetings for calendar integration. (#5462) + +* Meeting endpoints (`POST /meetings`, `PUT/DELETE /meetings/:domain/:id`, meeting invitation endpoints): errors have dedicated descriptions. (#5455) + +* `PUT /meetings/{domain}/{id}` now accepts an optional `tzid` field to update a + meeting's IANA time zone. (#5479) + +* Finalize api version 17. (#5482) + +* To prevent security-relevant configuration mistakes, make configuration of + allowed IdP certificate fingerprints (`idpCertFingerprintAllowlist`) mandatory for + multi-ingress SSO. **This will break existing multi-ingress SSO flows until + `idpCertFingerprintAllowlist` is configured!** This breakage is unfortunately + necessary, because we're getting more lenient regarding the IdPs a user can use + to log in ("auto IdP migration"). Regular (non-multi-ingress) use cases are + unaffected. (#5327) + +* +`GET /conversations/{domain}/{id}` and the legacy `GET /conversations/{id}` now return 404 (`no-conversation`) when the conversation is a meeting, on API versions prior to V16, instead of returning the conversation with `group_conv_type: null`. The legacy batch endpoint `GET /conversations?ids=…` — itself removed at V3, so only ever available on V1–V2 — likewise omits meeting conversations from its results. Meeting conversations remain fully accessible from V16 onwards. (WPB-26626) (#5382) + +* The `meetingsPremium` team feature endpoints are deprecated and return 404 for + clients on API version v17: the public `GET`/`PUT /teams/:tid/features/meetingsPremium` + and the internal legacy lock `PUT /i/teams/:tid/features/meetingsPremium/(un)?locked`. + They remain available through v16. The flag has had no behavioural effect since + WPB-26771 (team meetings are always non-trial). The aggregate endpoints + `GET /feature-configs` and `GET /teams/:tid/features` are unaffected and continue + to include `meetingsPremium` at all API versions: the aggregate feature list is + version-agnostic, like other version-gated features such as MLS. (WPB-26771) (#5364) + +* Reject meeting creation and update when the start time is in the past (with a 60-second tolerance for clock skew). Previously, meetings could be created with arbitrary past start times. (#5325) + +* GET /meetings/list and GET /meetings/{domain}/{id} no longer return 403 invalid-op when the caller's team has the `meetings` feature disabled. The read endpoints now treat a disabled feature as "no meetings": GET /meetings/list returns 200 [], and GET /meetings/{domain}/{id} returns 404 meeting-not-found. Write operations (create, update, delete, invitation mutations) still return 403 invalid-op when the feature is disabled. Previously these read endpoints returned an undocumented 403 invalid-op for members of teams with the meetings feature disabled. (#5353) + +* The meetings endpoints (POST /meetings, PUT /meetings/{domain}/{id}, GET /meetings/{domain}/{id}, GET /meetings/list) drop the deprecated `trial` field from the `Meeting` response starting at API version V17. On V15–V16 the field is still present but always returns `false` (team meetings are never trial; see WPB-26771). The underlying storage is unchanged. (#5363) + +* PUT /meetings/{domain}/{id} can now edit a meeting that has already started (an ongoing meeting). The start-time-not-in-the-past validation added by WPB-26773 previously rejected any update whose start_time was in the past, which also blocked legitimate edits to ongoing meetings — whose start time is naturally in the past; the check now applies only to meetings that have not started yet. Creating a meeting with a past start time, and moving an upcoming meeting's start time into the past, remain rejected (WPB-27465). (#5373) + +* Starting at API version V17, the `Meeting` type carries a `tzid` (IANA time + zone) and drops the deprecated `trial` field; `end_time` is retained on both V17 + and V16. A V17 update that supplies only `start_time` leaves `end_time` + unchanged — pass `end_time` to reschedule the end. (#5391) + + +## Features + + +* Manual team invitations now conflict when a matching pending SCIM invitation already exists for the same team and email address. (#5400) + +* Add user contact-status enrichment to user listings based on available Proteus and MLS contact methods. (#5371) + +* Introduce schedulable background jobs, migrate meetings cleanup to the new job runner, and add the initial adminless reminder and deletion jobs. (#5289) + +* Skip senderless prevent-adminless deletion for federated conversations with remote members to prevent remote state drift. (#5425) + +* Add adminless-group reconciliation, teardown, and system events for member updates, reminders, and deletion. (#5357, #5390) + +* Filter Wire Meetings lifecycle events only on the originating client connection. (#5428) + +* When a team uses multiple SAML IdPs (one per ingress domain) in a multi-ingress + setup, users can now authenticate via any of the team's IdPs even if their + account was originally provisioned under a different one. Spar resolves the + correct account by email-based NameID lookup across all team IdPs and migrates + the user's SSO identity to the authenticating IdP transparently. + + **Important:** Email addresses (`NameID`s) must be unique across configured + IdPs! Otherwise, users may be logged into wrong accounts! + + Please refer to the documentation for further information. (#5212) + +* * Added meeting lifecycle events: `meeting.create`, `meeting.update`, and + `meeting.delete` (WPB-26705). These websocket notifications are pushed to all + local members of the meeting's conversation on every successful create, update, + and delete operation. Each payload carries the event `type`, the meeting's + qualified ID in the top-level `qualified_id` field, the + `qualified_conversation`, `qualified_from`, `via`, `time`, and optional `team`. + Meeting events use a dedicated event envelope (not the conversation event + envelope). (#5330) + +* Added `meeting.member-add` websocket event (WPB-27620). When a user becomes a + member of an MLS meeting conversation, a `meeting.member-add` lifecycle event is + pushed to the newly-added local members, alongside the existing `meeting.create`, + `meeting.update`, and `meeting.delete` events. The payload uses the same meeting + event structure as the other meeting lifecycle events. (#5383) + + +## Bug fixes and other updates + + +* When a user is put under SCIM control, any pending email-address update is now invalidated (the unvalidated email and its activation token are removed). Previously, team settings kept offering a "resend verification" action that could not succeed (failing with `403 managed-by-scim`), and a stale activation link could still change a SCIM-managed user's email outside of SCIM. (#5333) + +* Release a handle claimed after a SCIM invitation expired, before cleanup, preventing a subsequent team invitation from using that handle. (#5400) + +* SCIM PATCH now supports the `emails` multi-valued attribute (e.g. Entra's + `emails[type eq "work"].value`), so user emails can be updated via SCIM. Identity + providers that previously hit a `can not lens into multi-valued attributes yet` + error when provisioning emails now succeed. (#5419) + +* SCIM email metadata is now persisted and echoed verbatim. spar stores the + `type` and `primary` sub-attributes of the SCIM email entry it keeps (in + `spar.scim_user_times`) and echoes them back on GET/POST/PATCH exactly as the + IdP sent them, instead of synthesizing a hardcoded `type` of `"work"` (email + `type` values are limited to 64 characters). As a result, PATCH value-path + filters like Entra's `emails[type eq "work"].value` and Okta's + `emails[primary eq true].value` match the stored entry for an in-place update + when (and only when) the IdP actually supplied that metadata at provisioning + time; users provisioned without it echo neither field. Since per RFC 7644 + §3.5.2 an `Add` on a non-existing target creates it, a type-filter PATCH + against a user whose stored email carries no such metadata appends a new + entry (which the single-email reduction collapses back to the old address, + i.e. no visible change), while a primary-filter PATCH is a complete no-op. (#5419) + +* SCIM user provisioning now rejects requests (HTTP 400) that mark more than one + email as `primary`, an RFC 7643 §2.4 violation. Previously spar silently picked + one primary and dropped the rest, masking client-side misconfiguration. Requests + with zero or one primary email are unchanged. (#5419) + +* Fixed asset uploads with non-ASCII filenames when audit logging is enabled. Audit-log metadata is now percent-encoded before being stored in S3 metadata headers and decoded when read back. (#5359) + +* Fix nginz routes for delete / update collaborator. Move collaborator CRUD api to galley. (#5334) + +* Users marked non-searchable are no longer returned across federation. (#5282) + +* If apps are re-enabled in the team, DO NOT re-activate any apps in the team. (#5347) + +* Wire Meetings lifecycle events (meeting.create, meeting.update, meeting.delete) are no longer delivered to the user who triggered the action, consistent with how other event types avoid echoing back to the originator. Previously the creator/updater/deleter received their own meeting event. (#5426) + +* Allow team admin to remove bot from all conversations, instead of crashing. (This is how it's already done in 'finishDeleteService'.) (#5450) + +* Fix openapi3 docs for oauth scopes. (#5457) + +* Enable claiming key packages for ephemeral users (#5339) + +* The federator internal listener's /i/status health check now correctly verifies the external listener is ready instead of checking itself, so readiness no longer reports up before the external federation listener is bound. (#5403) + +* Reorder SSO nginx locations to enforce correct rate limiting: + `/sso/get-by-email` needs to appear before `/sso` in nginx's config, because + regex locations are matched in order (first-match), not by specificity. In + previous order `/sso` caught before `/sso/get-by-email` applied the specific + 5r/m rate limit, leaving it on the generic 50r/s limit. (#5341) + +* Fixed Content Security Policy header scoping in multi-ingress Kubernetes configuration. CSP headers set via the Ingress nginx configuration-snippet are now properly scoped to exclude the webapp domain, preventing conflicts with the webapp's own CSP headers that are set independently. (#5432) + +* SCIM: Avoid assigning an already claimed handle to a user. + + Before this if SCIM created a user with a handle already claimed by another user + the handle would get stored for the user even if the overall SCIM call fails. (#5475) + +* SCIM PATCH on `emails` now handles the email `type` and `primary` + sub-attributes per RFC 7644 §3.5.2.2 and RFC 7643 §2.5: + `remove emails[type eq "work"].type` (or `.primary`) unassigns just that + sub-attribute and keeps the entry (including the address and the other + sub-attribute) instead of deleting the whole record; `replace`/`add` with an + explicit `"value": null` unassigns the sub-attribute the same way; and a + filterless `remove emails` clears all email entries. Removing or nulling the + `.value` sub-attribute is rejected with a 400 pointing at whole-entry removal, + since the address is the record's identity. As a consequence of preserving + explicit `null` values in PATCH operations, `replace` with `null` on + `displayName`/`externalId`/`active` now unassigns the attribute (RFC 7643 §2.5) + like the corresponding `remove` already did, instead of failing with + "No value was provided". (#5419) + +* Fixed a discrepancy between the canonical `searchVisibility` feature key used in runtime JSON and the legacy `teamSearchVisibility` key used in YAML configuration. Both names are now accepted; no configuration changes or other operator actions are required. (#5338) + + +## Internal changes + + +* Fix integration test cleanup on federation instance V2. (#5447) + +* Update postgres schema dump. (#5461) + +* Move email template completeness tests from brig to wire-subsystems. (#5429) + +* Rename `Wire.ScimUserTimesStore` to `Wire.ScimUserMetaStore` (`ScimUserTimes` -> `ScimUserMeta`); the store also holds SCIM email metadata now. The Cassandra table `spar.scim_user_times` is unchanged. (#5419) + +* Move `Spar.Sem.Reporter` to `Wire.Reporter` (#5377) + +* Move `Spar.Sem.SamlProtocolSettings` to `Wire.SamlProtocolSettings` (#5377) + +* Move `Brig.Effects.JwtTools` in `Wire.JwtTools`. (#5396) + +* Move `Brig.Budget` in `Wire.BudgetStore`. (#5397) + +* Move `Galley.Effects.Queue` in `Wire.BoundedQueue`. (#5398) + +* Move `Spar.Sem.ScimUserTimesStore` to `Wire.ScimUserTimesStore`. (#5378) + +* Move `Spar.Sem.DefaultSsoCode` in `Wire.DefaultSsoStore` (#5379) + +* Move `Spar.Sem.IdPRawMetadataStore` to `Wire.IdPRawMetadataStore`. (#5380) + +* Move `Spar.Sem.VerdictFormatStore` in `Wire.VerdictFormatStore`. (#5388) + +* Move `Spar.Sem.ScimExternalIdStore` in `Wire.ScimExternalIdStore`. (#5392, #5423) + +* Read the DPoP public key bundle once at startup and cache it, instead of + re-reading the file on every request; the `PublicKeyBundle` effect is removed. (#5393) + +* Move `Brig.Effects.UserPendingActivationStore` in `Wire.UserPendingActivationStore`. (#5394) + +* Move `Brig.Effects.SFT` in `Wire.SFT`. (#5395) + +* In multi-domain (multi-ingress) mode, the `wire-ingress` chart now applies the + `httpRoute.annotations` passthrough to every per-domain HTTPRoute, so external-dns + weighted-record annotations (set-identifier/aws-weight) work across all backend + domains. The `service.create` toggle also applies in multi-domain deployments. (#5307) + +* Extract MLS key-package handling into a subsystem and split Cassandra access into a dedicated store. (#5368) + +* Added partial indexes and rewrote the meetings cleanup query so the background + worker stays fully index-backed as the `meetings` table grows: + - `idx_meetings_recurrence_eff_end` on `GREATEST(end_time, recurrence_until)` + for bounded recurring meetings (covers the recurring branches of the list and + cleanup queries). + - `idx_meetings_end_time_nonrecurring` on `end_time` for non-recurring meetings, + so cleanup can find expired non-recurring meetings without scanning + not-yet-expired recurring rows whose original slot is long past. + `getOldMeetingsImpl` now issues one bounded, index-backed query per meeting kind + and merges the two batches. (#5328) + +* Added a `meetings_recurrence_consistency` CHECK constraint so the recurrence + columns can never be left in a partially-set state (frequency is the master + switch; interval is required when set; recurrence_until stays optional for + open-ended recurring meetings). (#5328) + +* The wire-ingress and nginx-ingress-services charts now expose annotation + passthroughs (`httpRoute.annotations` on the HTTPRoutes, `ingress.annotations` on + the Ingresses), so external-dns weighted records (set-identifier/aws-weight) can + be attached to both routers for a zero-downtime nginx-to-Envoy DNS cutover. + wire-ingress also gains a `service.create` toggle (default true) to reuse the + backend Services owned by nginx-ingress-services while both run in parallel. (#5358) + +* Make haddocks more readable. (#5446) + +* When using the wire-ingress provided EnvoyProxy, ensure the request path's query string is removed from the logs, to ensure access_tokens passed as query string are not logged. Browsers are required to pass them on a query string on some endpoints such as /await. (#5361) + +* Moved the following endpoints from development version V17 to the new development version V18: `PUT /conversations/:domain/:cnv/members` (rejection of replacements that would leave a group adminless), `PUT /teams/:tid/features/preventAdminlessGroups` (duration-string request body), and `POST /register` (403 for SCIM-managed users changing their name). V17 behaves like V16 for these endpoints. API version V18 was created as a development version; V17 remains a development version until finalized. The changelog entry for the moved members endpoint is parked in `changelog.d/99-pending/`, which `mk-changelog.sh` and `mk-cleanup.sh` now skip. (#5468) + +* Automate license header updates in treefmt. (#5481) + +* Cannon now logs an error when registering a client's remote presence with + Gundeck fails, so operators can tell this apart from an actual + websocket/network issue (e.g. `PongTimeout` caused by Gundeck losing its Redis + connection). (#5454) + +* The alpine base images used by the `cannon-configurator` initContainer (`wire-server` + chart) and the `job-done` container (`cassandra-migrations` chart) are no longer + hard-coded. They can now be set via `cannon.configuratorImage.{repository,tag,pullPolicy}` + in the `wire-server` chart and `jobDoneImage.{repository,tag}` in the + `cassandra-migrations` chart. + + The default was bumped from `alpine:3.21.3` to `alpine:3.24.1`, since alpine 3.21 + reaches end-of-support on 2026-11-01. The local integration stack's `init_vhosts` + container also moved from `alpine/curl:3.14` (an Alpine 3.14 image last published + in 2021) to `alpine/curl:8.21.0`. + + Operators who mirror images into a private registry should make sure the new + `alpine:3.24.1` tag is cached, or override `repository` to point at their mirror. + + The chart release tooling (`hack/bin/set-wire-server-image-version.sh`, + `hack/bin/set-chart-image-version.sh`) now anchors its version stamping to + `repository: quay.io/wire/` lines instead of matching `tag:` by indentation, so + third-party image tags in `values.yaml` are no longer overwritten with the + wire-server release version. (#5408) + +* Updated email templates to v1.0.155 (#5344) + +* Fix `#sbom` Nix env / sbomnix usage by upgrading to latest stable version of + the latter. The issue was introduced by upgrading `nixpkgs` to 26.05. + +* brig: Remove /i/users/rich-info (#5384) + +* remove sftd_disco (now lives in wireapp/wire-avs-service) (#5056) + +* Use wire image mirror for integration tests as `public.ecr.aws/bitnami/` is no + longer available + (https://aws.amazon.com/blogs/containers/bitnami-image-removal-from-ecr-public/). + Docker Hub has strict rate-limiting. So, in lieu of better options, we now use + our own image cache at `quay.io/wire/mirror-images`. (#5360) + +* Internal: `meetings.tzid` is now `NOT NULL`, backfilled to `Europe/Berlin`. + `end_time` is the source of truth (there are no `duration`/`duration_original` + columns). (WPB-27553) (#5391) + +* The `Z-Host` header has been treated as domain, but used as `Text`. + De-serializing and thus using it as `Domain` increases type-safety and ensures + domain related semantics; e.g. case insensitivity in equality checks. + This solves a `FUTUREWORK` remark which was around for quite some time. (#5320) + + +## Federation changes + + +* `deeplink.json` now contains a new optional field `supportEmail` that may be used by clients. (#5351) + + # [2026-07-07] (Chart Release 5.34.0) ## Release notes diff --git a/Makefile b/Makefile index a9a0200f74b..2a3250275e6 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ calling-test demo-smtp elasticsearch-curator elasticsearch-external \ elasticsearch-ephemeral minio-external cassandra-external \ ingress-nginx-controller nginx-ingress-services reaper \ k8ssandra-test-cluster ldap-scim-bridge wire-server-enterprise \ -wire-ingress +wire-ingress KIND_CLUSTER_NAME := wire-server HELM_PARALLELISM ?= 1 # 1 for sequential tests; 6 for all-parallel tests PSQL_DB ?= backendA @@ -276,15 +276,6 @@ formatf-all: formatc: ./tools/ormolu.sh -c -# For any Haskell or Rust file, update or add a license header if necessary. -# Headers should be added according to Ormolu's formatting rules, but please check just in case. -.PHONY: add-license -add-license: - command -v headroom - headroom run -a - @echo "" - @echo "you might want to run 'make formatf' now to make sure ormolu is happy" - # without redirecting stdin/-out/-err, emacs does something weird that takes 3-5 seconds. .PHONY: treefmt treefmt: @@ -356,9 +347,7 @@ cqlsh: .PHONY: psql psql: - @grep -q wire-server:wire-server ~/.pgpass || \ - echo "consider running 'echo localhost:5432:$(PSQL_DB):wire-server:posty-the-gres > ~/.pgpass ; chmod 600 ~/.pgpass '" - psql -h localhost -p 5432 $(PSQL_DB) -U wire-server -w || \ + PGPASSWORD=posty-the-gres psql -h localhost -p 5432 $(PSQL_DB) -U wire-server -w || \ echo 'if the database is missing, consider running "make postgres-reset", or setting $$PSQL_DB to the correct table space.' .PHONY: db-reset-package diff --git a/cabal.project b/cabal.project index 1cf1aa8ecbc..345b1d52076 100644 --- a/cabal.project +++ b/cabal.project @@ -1,6 +1,7 @@ repository hackage.haskell.org url: https://hackage.haskell.org/ index-state: 2023-10-03T15:17:00Z + packages: integration , libs/bilge/ diff --git a/cassandra-schema.cql b/cassandra-schema.cql index 7d2e47e16e0..9e5e1528992 100644 --- a/cassandra-schema.cql +++ b/cassandra-schema.cql @@ -1005,6 +1005,30 @@ CREATE TABLE brig_test.team_invitation_info ( AND read_repair = 'BLOCKING' AND speculative_retry = '99p'; +CREATE TABLE brig_test.team_scim_pending_user_email ( + team uuid, + email text, + user uuid, + PRIMARY KEY ((team, email), user) +) WITH CLUSTERING ORDER BY (user ASC) + AND additional_write_policy = '99p' + AND bloom_filter_fp_chance = 0.01 + AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} + AND cdc = false + AND comment = '' + AND compaction = {'class': 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold': '32', 'min_threshold': '4'} + AND compression = {'chunk_length_in_kb': '16', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'} + AND memtable = 'default' + AND crc_check_chance = 1.0 + AND default_time_to_live = 0 + AND extensions = {} + AND gc_grace_seconds = 864000 + AND max_index_interval = 2048 + AND memtable_flush_period_in_ms = 0 + AND min_index_interval = 128 + AND read_repair = 'BLOCKING' + AND speculative_retry = '99p'; + CREATE TABLE brig_test.unique_claims ( value text PRIMARY KEY, claims set @@ -2450,6 +2474,8 @@ CREATE TABLE spar_test.scim_external ( CREATE TABLE spar_test.scim_user_times ( uid uuid PRIMARY KEY, created_at timestamp, + email_primary boolean, + email_type text, last_updated_at timestamp ) WITH additional_write_policy = '99p' AND bloom_filter_fp_chance = 0.1 diff --git a/changelog.d/99-pending/WPB-27393 b/changelog.d/99-pending/WPB-27393 new file mode 100644 index 00000000000..df86814ba2d --- /dev/null +++ b/changelog.d/99-pending/WPB-27393 @@ -0,0 +1 @@ +V18 `PUT /conversations/{domain}/{conversation}/members` rejects replacements that would leave a regular group without an admin; V17 and older retain the legacy autopromotion behavior. (#5387) diff --git a/changelog.d/mk-changelog.sh b/changelog.d/mk-changelog.sh index aa8cc3fd3bd..167abeb7f70 100755 --- a/changelog.d/mk-changelog.sh +++ b/changelog.d/mk-changelog.sh @@ -15,6 +15,10 @@ get_pr_number() { for d in "$DIR"/*; do if [[ ! -d "$d" ]]; then continue; fi + # 99-pending: entries deferred out of the upcoming release. Since renaming a + # file breaks get_pr_number (git log does not follow renames), each parked + # entry must bake its PR number into its text, e.g. " (#1234)". + if [[ "$(basename "$d")" == "99-pending" ]]; then continue; fi entries=("$d"/*[^~]) diff --git a/changelog.d/mk-cleanup.sh b/changelog.d/mk-cleanup.sh index b483a6e6041..36c5a093a22 100755 --- a/changelog.d/mk-cleanup.sh +++ b/changelog.d/mk-cleanup.sh @@ -5,5 +5,9 @@ shopt -s nullglob DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -rm -f "$DIR"/*/* +for d in "$DIR"/*; do + [[ -d "$d" ]] || continue + if [[ "$(basename "$d")" == "99-pending" ]]; then continue; fi + rm -f "$d"/* +done git add "$DIR" diff --git a/charts/backoffice/templates/deployment.yaml b/charts/backoffice/templates/deployment.yaml index 19eba226347..fa75cb7f486 100644 --- a/charts/backoffice/templates/deployment.yaml +++ b/charts/backoffice/templates/deployment.yaml @@ -26,6 +26,7 @@ spec: # An annotation of the configmap checksum ensures changes to the configmap cause a redeployment upon `helm upgrade` checksum/configmap: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} spec: + automountServiceAccountToken: false volumes: - name: "backoffice-config" configMap: diff --git a/charts/calling-test/templates/deployment.yaml b/charts/calling-test/templates/deployment.yaml index a8acfa18b74..92f353e44d3 100644 --- a/charts/calling-test/templates/deployment.yaml +++ b/charts/calling-test/templates/deployment.yaml @@ -14,6 +14,7 @@ spec: labels: {{- include "calling-test.selectorLabels" . | nindent 8 }} spec: + automountServiceAccountToken: false containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Chart.AppVersion }}" diff --git a/charts/cassandra-migrations/templates/migrate-schema.yaml b/charts/cassandra-migrations/templates/migrate-schema.yaml index af51d187145..8cf6a1bfe82 100644 --- a/charts/cassandra-migrations/templates/migrate-schema.yaml +++ b/charts/cassandra-migrations/templates/migrate-schema.yaml @@ -166,7 +166,8 @@ spec: containers: - name: job-done - image: alpine:3.21.3 + image: "{{ .Values.jobDoneImage.repository }}:{{ .Values.jobDoneImage.tag }}" + imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }} {{- if eq (include "includeSecurityContext" .) "true" }} securityContext: {{- toYaml .Values.podSecurityContext | nindent 12 }} diff --git a/charts/cassandra-migrations/values.yaml b/charts/cassandra-migrations/values.yaml index a66fc6bc231..01993f08da1 100644 --- a/charts/cassandra-migrations/values.yaml +++ b/charts/cassandra-migrations/values.yaml @@ -79,6 +79,15 @@ enableBrigMigrations: true enableGundeckMigrations: true enableSparMigrations: true +# Image for the `job-done` container, which only runs a single `echo` to mark +# the migration Job complete. Not a wire image, so it is versioned +# independently of images.tag above. Override repository to pull from a +# mirror registry, e.g. my-mirror.example/library/alpine +# renovate: datasource=docker depName=alpine +jobDoneImage: + repository: alpine + tag: "3.24.1" + podSecurityContext: allowPrivilegeEscalation: false capabilities: diff --git a/charts/demo-smtp/templates/deployment.yaml b/charts/demo-smtp/templates/deployment.yaml index a24a4a0f3b0..ac3a9ff6634 100644 --- a/charts/demo-smtp/templates/deployment.yaml +++ b/charts/demo-smtp/templates/deployment.yaml @@ -19,6 +19,7 @@ spec: app: {{ template "demo-smtp.name" . }} release: {{ .Release.Name }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/elasticsearch-ephemeral/templates/es.yaml b/charts/elasticsearch-ephemeral/templates/es.yaml index 81832c6783b..ae8f5cfca5d 100644 --- a/charts/elasticsearch-ephemeral/templates/es.yaml +++ b/charts/elasticsearch-ephemeral/templates/es.yaml @@ -18,6 +18,7 @@ spec: labels: component: {{ template "fullname" . }} spec: + automountServiceAccountToken: false containers: - name: es image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" diff --git a/charts/elasticsearch-index/templates/migrate-data.yaml b/charts/elasticsearch-index/templates/migrate-data.yaml index ec23f91f8ca..bb59d83ff6d 100644 --- a/charts/elasticsearch-index/templates/migrate-data.yaml +++ b/charts/elasticsearch-index/templates/migrate-data.yaml @@ -61,8 +61,6 @@ spec: - {{ .Values.postgresqlPool.size | quote }} - --pg-pool-acquisition-timeout - {{ .Values.postgresqlPool.acquisitionTimeout | quote }} - - --pg-pool-aging-timeout - - {{ .Values.postgresqlPool.agingTimeout | quote }} - --pg-pool-idleness-timeout - {{ .Values.postgresqlPool.idlenessTimeout | quote }} {{- if hasKey $.Values.secrets "pgPassword" }} diff --git a/charts/elasticsearch-index/values.yaml b/charts/elasticsearch-index/values.yaml index 9aa145388fe..b12673ed181 100644 --- a/charts/elasticsearch-index/values.yaml +++ b/charts/elasticsearch-index/values.yaml @@ -46,7 +46,6 @@ postgresql: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m postgresMigration: diff --git a/charts/fake-aws-ses/templates/deployment.yaml b/charts/fake-aws-ses/templates/deployment.yaml index cb972a5a7b0..229e544e7d2 100644 --- a/charts/fake-aws-ses/templates/deployment.yaml +++ b/charts/fake-aws-ses/templates/deployment.yaml @@ -17,6 +17,7 @@ spec: labels: app: {{ template "fullname" . }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/fake-aws-sns/templates/deployment.yaml b/charts/fake-aws-sns/templates/deployment.yaml index d85b4770d36..82a6e951b97 100644 --- a/charts/fake-aws-sns/templates/deployment.yaml +++ b/charts/fake-aws-sns/templates/deployment.yaml @@ -17,6 +17,7 @@ spec: labels: app: {{ template "fullname" . }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/fake-aws-sqs/templates/deployment.yaml b/charts/fake-aws-sqs/templates/deployment.yaml index c8e024632b1..d3521235843 100644 --- a/charts/fake-aws-sqs/templates/deployment.yaml +++ b/charts/fake-aws-sqs/templates/deployment.yaml @@ -19,6 +19,7 @@ spec: annotations: checksum/configmap: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/integration/templates/configmap.yaml b/charts/integration/templates/configmap.yaml index a1b2592c310..02a9d4db410 100644 --- a/charts/integration/templates/configmap.yaml +++ b/charts/integration/templates/configmap.yaml @@ -102,6 +102,13 @@ data: adminPort: 15672 vHost: / + rabbitmq-v2: + host: rabbitmq.wire-federation-v2.svc.cluster.local + port: 5671 + adminHost: rabbitmq.wire-federation-v2.svc.cluster.local + adminPort: 15672 + vHost: / + backendTwo: brig: diff --git a/charts/nginx-ingress-services/templates/ingress.yaml b/charts/nginx-ingress-services/templates/ingress.yaml index 906d7f08dde..c0f1635efdc 100644 --- a/charts/nginx-ingress-services/templates/ingress.yaml +++ b/charts/nginx-ingress-services/templates/ingress.yaml @@ -2,16 +2,21 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ include "nginx-ingress-services.getIngressName" . | quote }} - {{- if .Values.config.renderCSPInIngress }} + {{- if or .Values.ingress.annotations .Values.config.renderCSPInIngress }} annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if .Values.config.renderCSPInIngress }} {{- if not (hasPrefix "nginx" .Values.config.ingressClass) }} {{ fail "In ingress CSP header setting only works with a 'nginx' controller. (Rename it to 'nginx-*' if it is one.)" }} {{- end }} {{/* In the non-multi-ingress (default) case, frontend apps (webapp, account-pages, team-settings) set CSP headers on their own. Implementing this for multi-ingress would have been much work (due to some framework - specifics). Thus, we are setting an approximation of the webapp's CSP - headers here. The SAML flow would be broken by setting these headers, so we + specifics). Thus, we are setting an approximation of CSP headers here; for + all services/apps besides the webapp that provides multi-ingress support + for CSP. The SAML flow would be broken by setting these headers, so we leave them out for it. This is fine, because in the default case they aren't set for any backend API endpoint as well. @@ -33,9 +38,18 @@ metadata: `/sso/finalize-login` is at the time of writing also used in Kalium, however not versioned. Though, because it is technically possible to use the endpoint versioned as well, it cannot hurt to be prepared for this. + + N.B. the nginx config language does not support complex if-expressions - + like nested if-s. So, we need to fallback to a more clumsy approach. */}} nginx.ingress.kubernetes.io/configuration-snippet: | - if ($uri !~ "^(/v[0-9]+)?/sso/(finalize-login|initiate-login)(/[a-zA-Z0-9-]*)?$|^/favicon\.ico$") { + if ($http_host = "{{ .Values.config.dns.webapp }}") { + set $skip_csp 1; + } + if ($uri ~ "^(/v[0-9]+)?/sso/(finalize-login|initiate-login)(/[a-zA-Z0-9-]*)?$|^/favicon\.ico$") { + set $skip_csp 1; + } + if ($skip_csp != 1) { set $CSP "connect-src 'self' blob: data: https://*.giphy.com https://{{ .Values.config.dns.https }}"; {{if .Values.websockets.enabled}} set $CSP "${CSP} wss://{{ .Values.config.dns.ssl }}"; @@ -58,6 +72,7 @@ metadata: set $CSP "${CSP} upgrade-insecure-requests"; more_set_headers "content-security-policy: $CSP"; } + {{- end }} {{- end }} spec: ingressClassName: "{{ .Values.config.ingressClass }}" diff --git a/charts/nginx-ingress-services/templates/ingress_federator.yaml b/charts/nginx-ingress-services/templates/ingress_federator.yaml index 4602fe98112..f9018c3129b 100644 --- a/charts/nginx-ingress-services/templates/ingress_federator.yaml +++ b/charts/nginx-ingress-services/templates/ingress_federator.yaml @@ -9,6 +9,9 @@ kind: Ingress metadata: name: federator-ingress annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/backend-protocol: "HTTP" nginx.ingress.kubernetes.io/auth-tls-verify-client: "on" diff --git a/charts/nginx-ingress-services/templates/ingress_minio.yaml b/charts/nginx-ingress-services/templates/ingress_minio.yaml index fed523e9e99..f21871faf38 100644 --- a/charts/nginx-ingress-services/templates/ingress_minio.yaml +++ b/charts/nginx-ingress-services/templates/ingress_minio.yaml @@ -6,6 +6,9 @@ kind: Ingress metadata: name: {{ include "nginx-ingress-services.getMinioIngressName" . | quote }} annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} nginx.ingress.kubernetes.io/server-snippet: | location /minio/ { return 403; diff --git a/charts/nginx-ingress-services/values.yaml b/charts/nginx-ingress-services/values.yaml index e77de580fb9..c9c7f88f0ec 100644 --- a/charts/nginx-ingress-services/values.yaml +++ b/charts/nginx-ingress-services/values.yaml @@ -126,6 +126,9 @@ service: accountPages: externalPort: 8080 +ingress: + # Annotations added to the Ingress resources created by this chart. + annotations: {} config: ingressClass: "nginx" # You will need to supply some DNS names, namely diff --git a/charts/nginz/templates/configmap.yaml b/charts/nginz/templates/configmap.yaml index 69f1887056e..6b159880996 100644 --- a/charts/nginz/templates/configmap.yaml +++ b/charts/nginz/templates/configmap.yaml @@ -42,6 +42,9 @@ data: ) "title" .Values.nginx_conf.deeplink.title }} + {{- with .Values.nginx_conf.deeplink.supportEmail }} + {{- $_ := set $deeplink "supportEmail" . }} + {{- end }} {{- if hasKey .Values.nginx_conf.deeplink "apiProxy" }} {{- $_ := set $deeplink "apiProxy" (dict "host" .Values.nginx_conf.deeplink.apiProxy.host @@ -77,6 +80,9 @@ data: ) "title" $config.title }} + {{- with $config.supportEmail }} + {{- $_ := set $deeplink "supportEmail" . }} + {{- end }} {{- if hasKey $config "apiProxy" }} {{- $_ := set $deeplink "apiProxy" (dict "host" $config.apiProxy.host diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index bbeb433b3e6..d114b3c7d35 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -62,6 +62,7 @@ nginx_conf: # accountsURL: "https://account.example.com" # blackListURL: "https://clientblacklist.example.com/prod" # websiteURL: "https://example.com" + # supportEmail: "support@example.com" # (optional) # apiProxy: # (optional) # host: "socks5.proxy.com" # port: 1080 @@ -78,6 +79,7 @@ nginx_conf: # teamsURL: "https://teams.red.example.com" # accountsURL: "https://account.red.example.com" # websiteURL: "https://red.example.com" + # supportEmail: "support@red.example.com" # (optional) # title: "Production red.example.com" # apiProxy: # (optional) # host: "socks5.proxy.com" @@ -446,12 +448,6 @@ nginx_conf: - path: /search envs: - all - - path: /teams/([^/]*)/collaborators$ - envs: - - all - - path: /teams/([^/]*)/collaborators/([^/]*) - envs: - - all - path: /teams/([^/]*)/apps$ envs: - all @@ -644,6 +640,10 @@ nginx_conf: - path: /conversations/([^/]*)/([^/]*)/add-permission envs: - all + - path: /conversations/([^/]*)/([^/]*)/name + envs: + - all + oauth_scope: conversations_name - path: /broadcast envs: - all @@ -685,6 +685,12 @@ nginx_conf: - path: /teams/([^/]*)/channels/search$ envs: - all + - path: /teams/([^/]*)/collaborators$ + envs: + - all + - path: /teams/([^/]*)/collaborators/([^/]*) + envs: + - all - path: /teams/([^/]*)/members(.*) envs: - all @@ -776,7 +782,16 @@ nginx_conf: disable_zauth: true basic_auth: true versioned: false - - path: /meetings(.*) + - path: /meetings$ + envs: + - all + oauth_scope: meetings + ## this rule can't be expressed yet: https://wearezeta.atlassian.net/browse/WPB-28193 + #- path: /meetings/([^/]*)/([^/]*)$ + # envs: + # - all + # oauth_scopes: [write:meetings, admin:meetings] + - path: /meetings/(.*) envs: - all gundeck: @@ -816,6 +831,12 @@ nginx_conf: allow_credentials: true specific_user_rate_limit: reqs_per_addr_sso specific_user_rate_limit_burst: "10" + - path: /sso/get-by-email$ + envs: + - all + disable_zauth: true + specific_user_rate_limit: reqs_per_addr_sso_get_by_email + specific_user_rate_limit_burst: "15" - path: /sso envs: - all @@ -829,12 +850,6 @@ nginx_conf: allow_credentials: true specific_user_rate_limit: reqs_per_addr_sso specific_user_rate_limit_burst: "10" - - path: /sso/get-by-email$ - envs: - - all - disable_zauth: true - specific_user_rate_limit: reqs_per_addr_sso_get_by_email - specific_user_rate_limit_burst: "15" - path: /scim envs: - all diff --git a/charts/openldap/templates/openldap.yaml b/charts/openldap/templates/openldap.yaml index 3a0fdb9f08b..12274bdd601 100644 --- a/charts/openldap/templates/openldap.yaml +++ b/charts/openldap/templates/openldap.yaml @@ -8,6 +8,7 @@ metadata: release: {{ .Release.Name }} heritage: {{ .Release.Service }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/outlook-addin/templates/deployment.yaml b/charts/outlook-addin/templates/deployment.yaml index 3a0ab24413d..d204cb94643 100644 --- a/charts/outlook-addin/templates/deployment.yaml +++ b/charts/outlook-addin/templates/deployment.yaml @@ -15,6 +15,7 @@ spec: labels: app: {{ include "outlook.fullname" . }} spec: + automountServiceAccountToken: false topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" diff --git a/charts/reaper/Chart.yaml b/charts/reaper/Chart.yaml index 2d8bd97f867..131654fa443 100644 --- a/charts/reaper/Chart.yaml +++ b/charts/reaper/Chart.yaml @@ -3,3 +3,8 @@ version: 0.0.42 name: reaper appVersion: 0.1.0 description: A helm charts to restart cannons if redis-ephemeal has died +annotations: + # must conform to https://github.com/helm/community/blob/main/hips/hip-0015.md + helm.sh/images: | + - name: kubectl + image: docker.io/alpine/kubectl:1.36.3 diff --git a/charts/reaper/README.md b/charts/reaper/README.md index 0a0e0b2652c..f4b73e4e670 100644 --- a/charts/reaper/README.md +++ b/charts/reaper/README.md @@ -14,3 +14,58 @@ messages. Here, this reaper will check that the `redis-ephemeral` pod is older t other `cannon`; if that is not the case, it kills the `cannon`s forcing clients to reconnect. +Image +----- + +The reaper runs `scripts/reaper.sh` through `kubectl`, so `image` must point at a +kubectl image that **contains a POSIX shell** at `/bin/sh`. Distroless kubectl images +do not ship one and the pod will fail to start. The script itself is POSIX sh, so +busybox `ash` is enough, bash not required. + +The image is fully configurable: + +```yaml +image: + registry: docker.io # set to "" for an unqualified repository + repository: alpine/kubectl + tag: 1.36.3 + digest: "" # e.g. "sha256:..."; takes precedence over tag + pullPolicy: IfNotPresent +imagePullSecrets: + - name: my-pull-secret +``` + +RBAC +---- + +The chart creates a namespaced `Role`/`RoleBinding` granting `get`, `list`, `watch` and +`delete` on pods, bound to a `-reaper` ServiceAccount. + +`watch` is required even though the script never watches anything explicitly: +`kubectl delete pod` blocks until the pod is gone and opens a watch to do so. Without it +the reaper deletes the first cannon and then hangs, without crashing. + +Earlier versions bound the ServiceAccount to `cluster-admin` through a fixed-name +`ClusterRoleBinding`, which gave the pod read access to every Secret in the cluster. +`helm upgrade` removes that binding and the old `reaper-role` ServiceAccount. Because +nothing is cluster-scoped any more and all names are release-scoped, several reaper +releases can now coexist in one cluster; previously a second release failed to install +with a `ClusterRoleBinding` ownership conflict. + +Runtime +------- + +The container runs as uid/gid 65534 with a read-only root filesystem and has resource +requests and limits. `nodeSelector`, `tolerations` and `affinity` are honoured. + +`checkIntervalSeconds` (default `15`) controls how long the script waits between checks. +Earlier versions listed pods once per second. + +Logs distinguish a failure to reach the API from "there are no matching pods", and +include the underlying error: + + Failed to list pods: Error from server (Forbidden): ... Skipping this iteration... + No cannon pods found. Doing nothing... + +Both cases previously printed `Failed to list pods. Skipping this iteration...`, so a +reaper that could not list pods at all looked exactly like an idle one. diff --git a/charts/reaper/scripts/reaper.sh b/charts/reaper/scripts/reaper.sh index d0f2679354e..f67049e76a6 100755 --- a/charts/reaper/scripts/reaper.sh +++ b/charts/reaper/scripts/reaper.sh @@ -1,27 +1,34 @@ -#!/usr/bin/env bash +#!/bin/sh # See the readme of the reaper chart. +# +# This is POSIX sh on purpose: the only actively maintained kubectl images that +# ship busybox ash, not bash. # we loop forever, and on transient errors sleep and try again. # setting -e would crash the pod on transient e.g. network errors, which isn't useful. -set -uo pipefail +set -u +# shellcheck disable=SC3040 # busybox ash supports pipefail +set -o pipefail -USAGE="$0 " +USAGE="$0 [INTERVAL_SECONDS]" NAMESPACE="${1:?$USAGE}" +INTERVAL="${2:-15}" -echo "Using namespace: $NAMESPACE" +echo "Using namespace: $NAMESPACE, check interval: ${INTERVAL}s" kill_all_cannons() { echo "Killing all cannons" - CANNON_PODS=$(kubectl -n "$NAMESPACE" get pods 2>/dev/null \ - | grep -e "cannon" \ - | awk '{ print $1 }') || { - echo "Failed to list cannon pods. Skipping this iteration..." + RAW_PODS=$(kubectl -n "$NAMESPACE" get pods 2>&1) || { + echo "Failed to list cannon pods: $RAW_PODS. Skipping this iteration..." return } + CANNON_PODS=$(echo "$RAW_PODS" | grep -e "cannon" | awk '{ print $1 }') || CANNON_PODS="" + # A here-document rather than a pipeline, so the loop runs in the current + # shell and the `exit 1` below actually terminates the script. while IFS= read -r cannon; do - if [[ -n "$cannon" ]]; then + if [ -n "$cannon" ]; then echo "Deleting $cannon" # If a single delete fails, we skip it but keep going. kubectl -n "$NAMESPACE" delete pod "$cannon" || { @@ -29,29 +36,33 @@ kill_all_cannons() { exit 1 } fi - done <<< "$CANNON_PODS" + done </dev/null \ - | grep -e "cannon" -e "redis-ephemeral") || { - echo "Failed to list pods. Skipping this iteration..." - sleep 60 - continue + # List first, filter second. Folding both into one pipeline made an API failure. + RAW_PODS=$(kubectl -n "$NAMESPACE" get pods --sort-by=.metadata.creationTimestamp 2>&1) || { + echo "Failed to list pods: $RAW_PODS. Skipping this iteration..." + sleep "$INTERVAL" + continue } + # Gather all pods that contain "cannon" or "redis-ephemeral", sorted by creation time + ALL_PODS=$(echo "$RAW_PODS" | grep -e "cannon" -e "redis-ephemeral") || ALL_PODS="" + # Check if we have any cannon pods at all if ! echo "$ALL_PODS" | grep -q "cannon"; then echo "No cannon pods found. Doing nothing..." - sleep 60 + sleep "$INTERVAL" continue fi # Check if we have any redis-ephemeral pods at all if ! echo "$ALL_PODS" | grep -q "redis-ephemeral"; then echo "No redis-ephemeral pod found. Doing nothing..." - sleep 60 + sleep "$INTERVAL" continue fi @@ -59,19 +70,20 @@ while true; do # Check which is oldest FIRST_POD=$(echo "$ALL_PODS" | head -n 1 | awk '{ print $1 }') - if [[ -z "$FIRST_POD" ]]; then + if [ -z "$FIRST_POD" ]; then echo "Could not determine the oldest pod from the list. Doing nothing..." - sleep 60 + sleep "$INTERVAL" continue fi - if [[ "$FIRST_POD" =~ "redis-ephemeral" ]]; then - echo "redis-ephemeral is the oldest pod, all good." - else - kill_all_cannons - fi + case "$FIRST_POD" in + *redis-ephemeral*) + echo "redis-ephemeral is the oldest pod, all good." + ;; + *) + kill_all_cannons + ;; + esac - echo "Sleep 1" - sleep 1 + sleep "$INTERVAL" done - diff --git a/charts/reaper/templates/_helpers.tpl b/charts/reaper/templates/_helpers.tpl new file mode 100644 index 00000000000..47fc05fa161 --- /dev/null +++ b/charts/reaper/templates/_helpers.tpl @@ -0,0 +1,26 @@ +{{/* Allow KubeVersion to be overridden. */}} +{{- define "kubeVersion" -}} + {{- default .Capabilities.KubeVersion.Version .Values.kubeVersionOverride -}} +{{- end -}} + +{{- define "includeSecurityContext" -}} + {{- (semverCompare ">= 1.24-0" (include "kubeVersion" .)) -}} +{{- end -}} + +{{/* Fully qualified image reference, digest taking precedence over tag. */}} +{{- define "reaper.image" -}} +{{- $repository := .Values.image.repository -}} +{{- if .Values.image.registry -}} +{{- $repository = printf "%s/%s" .Values.image.registry .Values.image.repository -}} +{{- end -}} +{{- if .Values.image.digest -}} +{{- printf "%s@%s" $repository .Values.image.digest -}} +{{- else -}} +{{- printf "%s:%s" $repository (.Values.image.tag | toString) -}} +{{- end -}} +{{- end -}} + +{{/* Release-scoped name for the ServiceAccount, Role and RoleBinding. */}} +{{- define "reaper.serviceAccountName" -}} +{{- printf "%s-reaper" .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/charts/reaper/templates/deployment.yaml b/charts/reaper/templates/deployment.yaml index f9f8fb6d9b7..9d50439dc5c 100644 --- a/charts/reaper/templates/deployment.yaml +++ b/charts/reaper/templates/deployment.yaml @@ -18,8 +18,16 @@ spec: labels: app: reaper release: {{ .Release.Name }} + annotations: + # Ensure changes to the script cause a redeployment upon `helm upgrade` + checksum/configmap: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }} spec: - serviceAccountName: reaper-role + serviceAccountName: {{ include "reaper.serviceAccountName" . }} + automountServiceAccountToken: true + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} topologySpreadConstraints: - maxSkew: 1 topologyKey: "kubernetes.io/hostname" @@ -29,12 +37,38 @@ spec: app: reaper containers: - name: reaper - image: {{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }} - command: ["/app/reaper.sh", "{{ .Release.Namespace }}"] + image: {{ include "reaper.image" . | quote }} + imagePullPolicy: {{ default "" .Values.image.pullPolicy | quote }} + command: ["/bin/sh", "/app/reaper.sh", "{{ .Release.Namespace }}", "{{ .Values.checkIntervalSeconds }}"] + {{- if eq (include "includeSecurityContext" .) "true" }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 12 }} + {{- end }} + env: + # kubectl writes its discovery cache below $HOME; the root + # filesystem is read-only, so point it at the emptyDir. + - name: HOME + value: /tmp volumeMounts: - name: reaper-script mountPath: /app readOnly: true + - name: tmp + mountPath: /tmp + resources: +{{ toYaml .Values.resources | indent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} volumes: - name: reaper-script configMap: @@ -43,4 +77,5 @@ spec: items: - key: reaper.sh path: reaper.sh - + - name: tmp + emptyDir: {} diff --git a/charts/reaper/templates/rbac.yaml b/charts/reaper/templates/rbac.yaml index 83862bb012b..5e4caafb6f2 100644 --- a/charts/reaper/templates/rbac.yaml +++ b/charts/reaper/templates/rbac.yaml @@ -1,17 +1,38 @@ -kind: ClusterRoleBinding +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "reaper.serviceAccountName" . }} + labels: + app: reaper + release: {{ .Release.Name }} +--- +# The reaper only ever lists and deletes pods in its own namespace, so a +# namespaced Role is sufficient. +# `watch` is required even though the script never watches anything explicitly. +kind: Role apiVersion: rbac.authorization.k8s.io/v1 metadata: - name: reaper-role -subjects: -- kind: ServiceAccount - name: reaper-role - namespace: {{ .Release.Namespace }} -roleRef: - kind: ClusterRole - name: cluster-admin - apiGroup: rbac.authorization.k8s.io + name: {{ include "reaper.serviceAccountName" . }} + labels: + app: reaper + release: {{ .Release.Name }} +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch", "delete"] --- -apiVersion: v1 -kind: ServiceAccount +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 metadata: - name: reaper-role + name: {{ include "reaper.serviceAccountName" . }} + labels: + app: reaper + release: {{ .Release.Name }} +roleRef: + kind: Role + name: {{ include "reaper.serviceAccountName" . }} + apiGroup: rbac.authorization.k8s.io +subjects: + - kind: ServiceAccount + name: {{ include "reaper.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} diff --git a/charts/reaper/values.yaml b/charts/reaper/values.yaml index fdc3fb5b46f..cf2f56cf9e4 100644 --- a/charts/reaper/values.yaml +++ b/charts/reaper/values.yaml @@ -1,13 +1,45 @@ image: - # Use a kubectl image that includes a shell (sh/bash). Distroless images will fail to exec the script. + # The reaper executes a shell script through kubectl, so this image must + # contain a POSIX shell at /bin/sh. Distroless kubectl images do not + # ship one and the pod will fail to start with them. + # + # Set `registry` to "" to use an unqualified repository (e.g. when mirroring + # into a registry configured as the daemon default). registry: docker.io - repository: bitnamilegacy/kubectl - tag: 1.32.4 + repository: alpine/kubectl + tag: 1.36.3 + # Optional: pin by digest (e.g. "sha256:abc..."). Takes precedence over `tag`. + digest: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# How long to wait between two checks, in seconds. The condition this chart +# watches for (a redis-ephemeral restart) is rare, so there is no reason to poll +# the API server aggressively. +checkIntervalSeconds: 15 + +resources: + requests: + memory: 32Mi + cpu: 10m + limits: + memory: 64Mi + +nodeSelector: {} +tolerations: [] +affinity: {} + +# Applied as the container securityContext. runAsUser/runAsGroup are set +# explicitly because alpine/kubectl runs as root by default. podSecurityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL + readOnlyRootFilesystem: true runAsNonRoot: true + runAsUser: 65534 + runAsGroup: 65534 seccompProfile: type: RuntimeDefault diff --git a/charts/wire-ingress/README.md b/charts/wire-ingress/README.md index d107cfa3a73..73433734acc 100644 --- a/charts/wire-ingress/README.md +++ b/charts/wire-ingress/README.md @@ -101,10 +101,10 @@ name overrides, etc.) can be found in `values.yaml`. | Old key | Reason | |---|---| | `config.ingressClass` | | -| `ingressName` | Multi-ingress out of scope | -| `config.isAdditionalIngress` | Multi-ingress out of scope | -| `config.renderCSPInIngress` | Multi-ingress out of scope | -| `config.dns.base` | Only used for CSP header rendering, which is a multi-ingress feature | +| `ingressName` | Replaced by `config.domains[].name` — see [Multi-ingress (multiple backend domains)](#multi-ingress-multiple-backend-domains) | +| `config.isAdditionalIngress` | Implicit — every `config.domains` entry after the first is an additional ingress | +| `config.renderCSPInIngress` | CSP is injected automatically on additional domains; opt out per-domain with `config.domains[].renderCSP: false` | +| `config.dns.base` | Replaced by `config.domains[].base` (used for the per-domain CSP wildcard) | | `tls.verify_depth` | Envoy Gateway `ClientTrafficPolicy` does not expose a direct verify-depth knob; the CA chain itself controls this | | `tls.enabled` | Removed — had no effect; all routes are always TLS-terminated | | `secrets.tlsClientCA` | No longer supplied via values. The `federator-ca` ConfigMap is created by the wire-server chart and referenced directly. | @@ -232,9 +232,31 @@ the name is predictable from chart values. --- -### Multi-ingress is out of scope +### Multi-ingress (multiple backend domains) -Single-domain deployments are the only supported topology. Multi-domain support can be added later. +Set `config.domains` **instead of** `config.dns` to serve several domains from one release: + +```yaml +config: + domains: + - name: blueberry + base: blueberry.example.com + dns: { https: nginz-https.blueberry.example.com, ssl: nginz-ssl.blueberry.example.com, webapp: webapp.blueberry.example.com } + - name: red + base: red.example.org + dns: { https: nginz-https.red.example.org, ssl: nginz-ssl.red.example.org, webapp: webapp.red.example.org } + tls: { issuer: { name: letsencrypt-red, kind: ClusterIssuer } } # optional per-domain issuer +``` + +First entry = primary (listener `https`, un-suffixed names, no injected CSP — apps set their own). +Each additional entry gets its own listener `https-`, cert/secret, suffixed routes, and an +injected per-domain CSP header on the webapp/team-settings/account-pages routes (opt out with +`renderCSP: false`). + +Multi-ingress is mutually exclusive with federation: `config.domains` cannot be +combined with `federator.enabled: true`. Use federation with a single backend +domain (`config.dns`), or multi-ingress (`config.domains`) with the federator +disabled — setting both fails template rendering with a clear error. ### HTTP01 certificate challenges diff --git a/charts/wire-ingress/templates/_helpers.tpl b/charts/wire-ingress/templates/_helpers.tpl index 263f190c90c..f780f7f35c5 100644 --- a/charts/wire-ingress/templates/_helpers.tpl +++ b/charts/wire-ingress/templates/_helpers.tpl @@ -73,3 +73,136 @@ Name of the Gateway resource. Uses gateway.name if set, otherwise derives one fr {{ include "wire-ingress.fullname" . }}-gateway {{- end -}} {{- end -}} + +{{/* +Normalized list of ingress domains, returned as a JSON array so callers can +`fromJsonArray` and range over it. + +Back-compat: when `config.domains` is NOT set, a single "primary" entry is +derived from the legacy scalar `config.dns` + `gateway.listeners.https.hostname`, +so existing single-domain deployments render exactly as before. + +Multi-domain: `config.domains` is a list; the FIRST entry is the primary +(its resources keep the un-suffixed names, and its frontend apps set their own +CSP so no CSP is injected). Every additional entry gets a `-` suffix, its +own Gateway listener (`https-`), its own certificate/secret, and — being +an "additional ingress" — a per-domain CSP header injected on the app routes. + +Each entry has: suffix, section, hostname, https, ssl, webapp, teamSettings, +accountPages, fakeS3, base, secretName, certName, issuerName, issuerKind, +primary (bool), csp (bool). +*/}} +{{- define "wire-ingress.domains" -}} +{{- $root := . -}} +{{- $fullname := include "wire-ingress.fullname" . -}} +{{- $out := list -}} +{{- if .Values.config.domains -}} + {{/* + Multi-ingress and federation are mutually exclusive. Multi-ingress serves one + backend on several unrelated domains to obfuscate client relationships; the + federator, by contrast, is single-domain and identifies the backend to other + backends. Supporting both at once is out of scope, so fail fast rather than + render a half-working federator on top of a multi-ingress deployment. + */}} + {{- if .Values.federator.enabled -}} + {{- fail "config.domains (multi-ingress) is mutually exclusive with federator.enabled (federation). Choose one: federation with a single backend domain via config.dns, OR multi-ingress via config.domains with federator.enabled=false." -}} + {{- end -}} + {{- range $i, $domain := .Values.config.domains -}} + {{- $primary := eq $i 0 -}} + {{- $name := required "each config.domains entry requires a 'name'" $domain.name -}} + {{- $base := required (printf "config.domains[%d] (%s) requires a 'base' domain" $i $name) $domain.base -}} + {{- $dns := required (printf "config.domains[%d] (%s) requires a 'dns' map" $i $name) $domain.dns -}} + {{- $tls := $domain.tls | default dict -}} + {{- $issuer := $tls.issuer | default dict -}} + {{- $suffix := ternary "" (printf "-%s" $name) $primary -}} + {{- $section := ternary "https" (printf "https-%s" $name) $primary -}} + {{- $secretName := "" -}} + {{- if $tls.secretName -}}{{- $secretName = $tls.secretName -}} + {{- else if $primary -}}{{- $secretName = include "wire-ingress.certificateSecretName" $root -}} + {{- else -}}{{- $secretName = printf "%s-%s-tls-certificate" $fullname $name -}}{{- end -}} + {{- $cspFlag := not $primary -}} + {{- if hasKey $domain "renderCSP" -}}{{- $cspFlag = $domain.renderCSP -}}{{- end -}} + {{/* + Additional domains cannot share the single wildcard secret created by + secret.yaml, and no cert-manager Certificate is rendered when + tls.useCertManager is false. Without a per-domain tls.secretName the Gateway + listener would reference a Secret that nothing ever creates, silently + failing TLS at runtime. Fail fast instead. + */}} + {{- if and (not $primary) (not $root.Values.tls.useCertManager) (not $tls.secretName) -}} + {{- fail (printf "config.domains[%d] (%s): additional domains need their own TLS secret, but tls.useCertManager is false and no config.domains[%d].tls.secretName is set. Either enable cert-manager (tls.useCertManager: true) or point tls.secretName at a pre-created kubernetes.io/tls Secret for this domain." $i $name $i) -}} + {{- end -}} + {{- $entry := dict + "suffix" $suffix + "section" $section + "hostname" ($domain.hostname | default (printf "*.%s" $base)) + "https" (required (printf "config.domains[%d] (%s) requires dns.https" $i $name) $dns.https) + "ssl" ($dns.ssl | default "") + "webapp" ($dns.webapp | default "") + "teamSettings" ($dns.teamSettings | default "") + "accountPages" ($dns.accountPages | default "") + "fakeS3" ($dns.fakeS3 | default "") + "base" $base + "secretName" $secretName + "certName" (printf "%s-csr" ($base | replace "." "-")) + "issuerName" ($issuer.name | default $root.Values.tls.issuer.name) + "issuerKind" ($issuer.kind | default $root.Values.tls.issuer.kind) + "primary" $primary + "csp" $cspFlag -}} + {{- $out = append $out $entry -}} + {{- end -}} +{{- else -}} + {{- $dns := .Values.config.dns -}} + {{- $base := include "wire-ingress.zone" . -}} + {{- $entry := dict + "suffix" "" + "section" "https" + "hostname" .Values.gateway.listeners.https.hostname + "https" (required "config.dns.https is required" $dns.https) + "ssl" ($dns.ssl | default "") + "webapp" ($dns.webapp | default "") + "teamSettings" ($dns.teamSettings | default "") + "accountPages" ($dns.accountPages | default "") + "fakeS3" ($dns.fakeS3 | default "") + "base" $base + "secretName" (include "wire-ingress.certificateSecretName" .) + "certName" (printf "%s-csr" ($base | replace "." "-")) + "issuerName" .Values.tls.issuer.name + "issuerKind" .Values.tls.issuer.kind + "primary" true + "csp" false -}} + {{- $out = append $out $entry -}} +{{- end -}} +{{- $out | toJson -}} +{{- end -}} + +{{/* +Content-Security-Policy header value for an "additional ingress" domain. +This mirrors the approximation the legacy nginx-ingress-services chart injected +for multi-ingress domains (charts/nginx-ingress-services/templates/ingress.yaml), +where the primary domain's frontend apps set CSP themselves but additional +domains need the header set at the front door. + +Call with a dict: {https, ssl, base, websockets (bool)}. +*/}} +{{- define "wire-ingress.cspHeader" -}} +{{- $csp := printf "connect-src 'self' blob: data: https://*.giphy.com https://%s" .https -}} +{{- if and .websockets .ssl -}}{{- $csp = printf "%s wss://%s" $csp .ssl -}}{{- end -}} +{{- $csp = printf "%s https://*.%s;" $csp .base -}} +{{- $csp = printf "%s default-src 'self';" $csp -}} +{{- $csp = printf "%s font-src 'self' data:;" $csp -}} +{{- $csp = printf "%s frame-src https://*.soundcloud.com https://*.spotify.com https://*.vimeo.com https://*.youtube-nocookie.com;" $csp -}} +{{- $csp = printf "%s img-src 'self' blob: data: https://*.giphy.com https://*.%s;" $csp .base -}} +{{- $csp = printf "%s manifest-src 'self';" $csp -}} +{{- $csp = printf "%s media-src 'self' blob: data:;" $csp -}} +{{- $csp = printf "%s object-src 'none';" $csp -}} +{{- $csp = printf "%s script-src 'self' 'unsafe-eval' https://*.%s;" $csp .base -}} +{{- $csp = printf "%s style-src 'self' 'unsafe-inline';" $csp -}} +{{- $csp = printf "%s worker-src 'self' blob:;" $csp -}} +{{- $csp = printf "%s base-uri 'self';" $csp -}} +{{- $csp = printf "%s form-action 'self';" $csp -}} +{{- $csp = printf "%s frame-ancestors 'self';" $csp -}} +{{- $csp = printf "%s script-src-attr 'none';" $csp -}} +{{- $csp = printf "%s upgrade-insecure-requests" $csp -}} +{{- $csp -}} +{{- end -}} diff --git a/charts/wire-ingress/templates/certificate-federator.yaml b/charts/wire-ingress/templates/certificate-federator.yaml index 0e5ef5219cc..1acebfe1947 100644 --- a/charts/wire-ingress/templates/certificate-federator.yaml +++ b/charts/wire-ingress/templates/certificate-federator.yaml @@ -1,3 +1,6 @@ +{{- if and .Values.federator.enabled .Values.config.domains -}} +{{- fail "config.domains (multi-ingress) is mutually exclusive with federator.enabled (federation). Choose one: federation with a single backend domain via config.dns, OR multi-ingress via config.domains with federator.enabled=false." -}} +{{- end -}} {{- if and .Values.federator.enabled .Values.federator.tls.useCertManager }} apiVersion: cert-manager.io/v1 kind: Certificate diff --git a/charts/wire-ingress/templates/certificate.yaml b/charts/wire-ingress/templates/certificate.yaml index 61ee9cb272b..2bb262c7d2f 100644 --- a/charts/wire-ingress/templates/certificate.yaml +++ b/charts/wire-ingress/templates/certificate.yaml @@ -1,45 +1,50 @@ {{- if .Values.tls.useCertManager -}} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +--- apiVersion: cert-manager.io/v1 kind: Certificate metadata: - name: "{{ include "wire-ingress.zone" . | replace "." "-" }}-csr" - namespace: {{ .Release.Namespace }} + name: "{{ $domain.certName }}" + namespace: {{ $root.Release.Namespace }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: issuerRef: - name: {{ include "wire-ingress.issuerName" . | quote }} - kind: {{ .Values.tls.issuer.kind }} + name: {{ $domain.issuerName | quote }} + kind: {{ $domain.issuerKind }} usages: - server auth duration: 2160h # 90d, Letsencrypt default; NOTE: changes are ignored by Letsencrypt renewBefore: 360h # 15d isCA: false - secretName: {{ include "wire-ingress.certificateSecretName" . | quote }} + secretName: {{ $domain.secretName | quote }} privateKey: - algorithm: {{ .Values.tls.privateKey.algorithm }} - size: {{ .Values.tls.privateKey.size }} + algorithm: {{ $root.Values.tls.privateKey.algorithm }} + size: {{ $root.Values.tls.privateKey.size }} encoding: PKCS1 - rotationPolicy: {{ .Values.tls.privateKey.rotationPolicy }} + rotationPolicy: {{ $root.Values.tls.privateKey.rotationPolicy }} dnsNames: - - {{ .Values.config.dns.https }} - {{- if .Values.websockets.enabled }} - - {{ .Values.config.dns.ssl }} + - {{ $domain.https }} + {{- if and $root.Values.websockets.enabled $domain.ssl }} + - {{ $domain.ssl }} {{- end }} - {{- if .Values.webapp.enabled }} - - {{ .Values.config.dns.webapp }} + {{- if and $root.Values.webapp.enabled $domain.webapp }} + - {{ $domain.webapp }} {{- end }} - {{- if .Values.fakeS3.enabled }} - - {{ .Values.config.dns.fakeS3 }} + {{- if and $root.Values.fakeS3.enabled $domain.fakeS3 }} + - {{ $domain.fakeS3 }} {{- end }} - {{- if .Values.teamSettings.enabled }} - - {{ .Values.config.dns.teamSettings }} + {{- if and $root.Values.teamSettings.enabled $domain.teamSettings }} + - {{ $domain.teamSettings }} {{- end }} - {{- if .Values.accountPages.enabled }} - - {{ .Values.config.dns.accountPages }} + {{- if and $root.Values.accountPages.enabled $domain.accountPages }} + - {{ $domain.accountPages }} {{- end }} +{{- end }} {{- end -}} diff --git a/charts/wire-ingress/templates/envoyproxy.yaml b/charts/wire-ingress/templates/envoyproxy.yaml index 12ff6f42b32..974a508e2ac 100644 --- a/charts/wire-ingress/templates/envoyproxy.yaml +++ b/charts/wire-ingress/templates/envoyproxy.yaml @@ -1,5 +1,50 @@ {{- if (and .Values.gateway.create .Values.gateway.envoyProxy.create) }} {{- $name := .Values.gateway.envoyProxy.name | default (include "wire-ingress.gatewayName" .) }} +{{- /* + Envoy Gateway access logs include the request path by default, including the + query string. We intentionally strip it here because browser-only flows may + need to send access_token as a query parameter, and that must never end up in + access logs. + + Envoy Gateway requires an explicit sink when accessLog settings are set, so we + send the redacted format to /dev/stdout through a File sink. +*/ -}} +{{- $accessLogJSON := dict + "start_time" "%START_TIME%" + "method" "%REQ(:METHOD)%" + "x-envoy-origin-path" "%PATH(NQ:ORIG_OR_PATH)%" + "protocol" "%PROTOCOL%" + "response_code" "%RESPONSE_CODE%" + "response_flags" "%RESPONSE_FLAGS%" + "response_code_details" "%RESPONSE_CODE_DETAILS%" + "connection_termination_details" "%CONNECTION_TERMINATION_DETAILS%" + "upstream_transport_failure_reason" "%UPSTREAM_TRANSPORT_FAILURE_REASON%" + "bytes_received" "%BYTES_RECEIVED%" + "bytes_sent" "%BYTES_SENT%" + "duration" "%DURATION%" + "x-envoy-upstream-service-time" "%RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)%" + "x-forwarded-for" "%REQ(X-FORWARDED-FOR)%" + "user-agent" "%REQ(USER-AGENT)%" + "x-request-id" "%REQ(X-REQUEST-ID)%" + ":authority" "%REQ(:AUTHORITY)%" + "upstream_host" "%UPSTREAM_HOST%" + "upstream_cluster" "%UPSTREAM_CLUSTER%" + "upstream_local_address" "%UPSTREAM_LOCAL_ADDRESS%" + "downstream_local_address" "%DOWNSTREAM_LOCAL_ADDRESS%" + "downstream_remote_address" "%DOWNSTREAM_REMOTE_ADDRESS%" + "requested_server_name" "%REQUESTED_SERVER_NAME%" + "route_name" "%ROUTE_NAME%" +-}} +{{- $accessLogSink := dict "type" "File" "file" (dict "path" "/dev/stdout") -}} +{{- $accessLogSetting := dict "format" (dict "type" "JSON" "json" $accessLogJSON) "sinks" (list $accessLogSink) -}} +{{- $defaultTelemetry := dict "telemetry" (dict "accessLog" (dict "settings" (list $accessLogSetting))) -}} +{{- $spec := deepCopy $defaultTelemetry }} +{{- with .Values.gateway.envoyProxy.spec }} +{{- $spec = mergeOverwrite $spec . }} +{{- end }} +{{- if .Values.gateway.manageServiceType }} +{{- $_ := set $spec "provider" (dict "type" "Kubernetes" "kubernetes" (dict "envoyService" (dict "type" .Values.gateway.serviceType))) }} +{{- end }} apiVersion: gateway.envoyproxy.io/v1alpha1 kind: EnvoyProxy metadata: @@ -10,14 +55,5 @@ metadata: release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" spec: - {{- with .Values.gateway.envoyProxy.spec }} - {{- toYaml . | nindent 2 }} - {{- end }} - {{- if .Values.gateway.manageServiceType }} - provider: - type: Kubernetes - kubernetes: - envoyService: - type: {{ .Values.gateway.serviceType | quote }} - {{- end }} +{{- toYaml $spec | nindent 2 }} {{- end }} diff --git a/charts/wire-ingress/templates/gateway.yaml b/charts/wire-ingress/templates/gateway.yaml index 16b3f7bb720..b547aae3e60 100644 --- a/charts/wire-ingress/templates/gateway.yaml +++ b/charts/wire-ingress/templates/gateway.yaml @@ -33,15 +33,18 @@ spec: {{- end }} {{- end }} listeners: - - name: https - port: {{ .Values.gateway.listeners.https.port }} + {{- $domains := include "wire-ingress.domains" . | fromJsonArray }} + {{- range $domain := $domains }} + - name: {{ $domain.section }} + port: {{ $.Values.gateway.listeners.https.port }} protocol: HTTPS - hostname: {{ required "gateway.listeners.https.hostname is required (see values.yaml for details)" .Values.gateway.listeners.https.hostname | quote }} + hostname: {{ required "an HTTPS listener hostname is required (gateway.listeners.https.hostname for single-domain, or config.domains[].base/hostname)" $domain.hostname | quote }} tls: mode: Terminate certificateRefs: - - name: {{ include "wire-ingress.certificateSecretName" . | quote }} + - name: {{ $domain.secretName | quote }} kind: Secret + {{- end }} {{- if .Values.federator.enabled }} - name: federator port: {{ .Values.gateway.listeners.https.port }} diff --git a/charts/wire-ingress/templates/httproute-account-pages.yaml b/charts/wire-ingress/templates/httproute-account-pages.yaml index c3ef24a374b..385876a2e31 100644 --- a/charts/wire-ingress/templates/httproute-account-pages.yaml +++ b/charts/wire-ingress/templates/httproute-account-pages.yaml @@ -1,28 +1,47 @@ {{- if .Values.accountPages.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.accountPages }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-account-pages - namespace: {{ .Release.Namespace }} + name: {{ include "wire-ingress.fullname" $root }}-account-pages{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.accountPages is required when accountPages.enabled is true" .Values.config.dns.accountPages | quote }} + - {{ required "config.dns.accountPages is required when accountPages.enabled is true" $domain.accountPages | quote }} rules: - matches: - path: type: PathPrefix value: / + {{- if $domain.csp }} + filters: + - type: ResponseHeaderModifier + responseHeaderModifier: + set: + - name: Content-Security-Policy + value: {{ include "wire-ingress.cspHeader" (dict "https" $domain.https "ssl" $domain.ssl "base" $domain.base "websockets" $root.Values.websockets.enabled) | quote }} + {{- end }} backendRefs: - name: account-pages-http - port: {{ .Values.service.accountPages.externalPort }} + port: {{ $root.Values.service.accountPages.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-federator.yaml b/charts/wire-ingress/templates/httproute-federator.yaml index a747c27d8c2..792d5045ce9 100644 --- a/charts/wire-ingress/templates/httproute-federator.yaml +++ b/charts/wire-ingress/templates/httproute-federator.yaml @@ -4,6 +4,10 @@ kind: HTTPRoute metadata: name: {{ include "wire-ingress.fullname" . }}-federator namespace: {{ .Release.Namespace }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" release: "{{ .Release.Name }}" diff --git a/charts/wire-ingress/templates/httproute-nginz-websockets.yaml b/charts/wire-ingress/templates/httproute-nginz-websockets.yaml index 5d9881d98d9..6a49f499e32 100644 --- a/charts/wire-ingress/templates/httproute-nginz-websockets.yaml +++ b/charts/wire-ingress/templates/httproute-nginz-websockets.yaml @@ -1,21 +1,30 @@ {{- if .Values.websockets.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.ssl }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-nginz-websockets - namespace: {{ .Release.Namespace }} + name: {{ include "wire-ingress.fullname" $root }}-nginz-websockets{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.ssl is required when websockets.enabled is true" .Values.config.dns.ssl | quote }} + - {{ required "config.dns.ssl is required when websockets.enabled is true" $domain.ssl | quote }} rules: - matches: - path: @@ -23,6 +32,8 @@ spec: value: / backendRefs: - name: nginz - port: {{ .Values.service.nginz.wsPort }} + port: {{ $root.Values.service.nginz.wsPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-nginz.yaml b/charts/wire-ingress/templates/httproute-nginz.yaml index 7bc50bb2890..6d020f232a6 100644 --- a/charts/wire-ingress/templates/httproute-nginz.yaml +++ b/charts/wire-ingress/templates/httproute-nginz.yaml @@ -1,20 +1,28 @@ +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-nginz - namespace: {{ .Release.Namespace }} + name: {{ include "wire-ingress.fullname" $root }}-nginz{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.https is required" .Values.config.dns.https | quote }} + - {{ required "config.dns.https is required" $domain.https | quote }} rules: - matches: - path: @@ -22,5 +30,6 @@ spec: value: / backendRefs: - name: nginz - port: {{ .Values.service.nginz.httpPort }} + port: {{ $root.Values.service.nginz.httpPort }} kind: Service +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-s3.yaml b/charts/wire-ingress/templates/httproute-s3.yaml index afb68412d74..a5e0cd2a3b5 100644 --- a/charts/wire-ingress/templates/httproute-s3.yaml +++ b/charts/wire-ingress/templates/httproute-s3.yaml @@ -1,30 +1,41 @@ {{- if .Values.fakeS3.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.fakeS3 }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-minio - namespace: {{ .Release.Namespace }} + name: {{ include "wire-ingress.fullname" $root }}-minio{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.fakeS3 is required when fakeS3.enabled is true" .Values.config.dns.fakeS3 | quote }} + - {{ required "config.dns.fakeS3 is required when fakeS3.enabled is true" $domain.fakeS3 | quote }} rules: - {{- toYaml .Values.fakeS3.guardingRules | nindent 4 }} + {{- toYaml $root.Values.fakeS3.guardingRules | nindent 4 }} {{/* Default catch-all rule routes to the S3 backend */}} - matches: - path: type: PathPrefix value: / backendRefs: - - name: {{ .Values.service.s3.serviceName }} - port: {{ .Values.service.s3.externalPort }} + - name: {{ $root.Values.service.s3.serviceName }} + port: {{ $root.Values.service.s3.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-team-settings.yaml b/charts/wire-ingress/templates/httproute-team-settings.yaml index 2de4cc0ea49..54f26f28447 100644 --- a/charts/wire-ingress/templates/httproute-team-settings.yaml +++ b/charts/wire-ingress/templates/httproute-team-settings.yaml @@ -1,28 +1,47 @@ {{- if .Values.teamSettings.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.teamSettings }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-team-settings - namespace: {{ .Release.Namespace }} + name: {{ include "wire-ingress.fullname" $root }}-team-settings{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.teamSettings is required when teamSettings.enabled is true" .Values.config.dns.teamSettings | quote }} + - {{ required "config.dns.teamSettings is required when teamSettings.enabled is true" $domain.teamSettings | quote }} rules: - matches: - path: type: PathPrefix value: / + {{- if $domain.csp }} + filters: + - type: ResponseHeaderModifier + responseHeaderModifier: + set: + - name: Content-Security-Policy + value: {{ include "wire-ingress.cspHeader" (dict "https" $domain.https "ssl" $domain.ssl "base" $domain.base "websockets" $root.Values.websockets.enabled) | quote }} + {{- end }} backendRefs: - name: team-settings-http - port: {{ .Values.service.teamSettings.externalPort }} + port: {{ $root.Values.service.teamSettings.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/httproute-webapp.yaml b/charts/wire-ingress/templates/httproute-webapp.yaml index 158836040d1..07844ec80a9 100644 --- a/charts/wire-ingress/templates/httproute-webapp.yaml +++ b/charts/wire-ingress/templates/httproute-webapp.yaml @@ -1,28 +1,47 @@ {{- if .Values.webapp.enabled }} +{{- $root := . -}} +{{- $domains := include "wire-ingress.domains" . | fromJsonArray -}} +{{- range $domain := $domains }} +{{- if $domain.webapp }} +--- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: - name: {{ include "wire-ingress.fullname" . }}-webapp - namespace: {{ .Release.Namespace }} + name: {{ include "wire-ingress.fullname" $root }}-webapp{{ $domain.suffix }} + namespace: {{ $root.Release.Namespace }} + {{- with $root.Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} labels: - chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" - release: "{{ .Release.Name }}" - heritage: "{{ .Release.Service }}" + chart: "{{ $root.Chart.Name }}-{{ $root.Chart.Version }}" + release: "{{ $root.Release.Name }}" + heritage: "{{ $root.Release.Service }}" spec: parentRefs: - - name: {{ include "wire-ingress.gatewayName" . | quote }} - namespace: {{ .Release.Namespace | quote }} + - name: {{ include "wire-ingress.gatewayName" $root | quote }} + namespace: {{ $root.Release.Namespace | quote }} kind: Gateway - sectionName: https + sectionName: {{ $domain.section }} hostnames: - - {{ required "config.dns.webapp is required when webapp.enabled is true" .Values.config.dns.webapp | quote }} + - {{ required "config.dns.webapp is required when webapp.enabled is true" $domain.webapp | quote }} rules: - matches: - path: type: PathPrefix value: / + {{- if $domain.csp }} + filters: + - type: ResponseHeaderModifier + responseHeaderModifier: + set: + - name: Content-Security-Policy + value: {{ include "wire-ingress.cspHeader" (dict "https" $domain.https "ssl" $domain.ssl "base" $domain.base "websockets" $root.Values.websockets.enabled) | quote }} + {{- end }} backendRefs: - name: webapp-http - port: {{ .Values.service.webapp.externalPort }} + port: {{ $root.Values.service.webapp.externalPort }} kind: Service {{- end }} +{{- end }} +{{- end }} diff --git a/charts/wire-ingress/templates/service-account-pages.yaml b/charts/wire-ingress/templates/service-account-pages.yaml index 7a77dbea0d6..f3d43252903 100644 --- a/charts/wire-ingress/templates/service-account-pages.yaml +++ b/charts/wire-ingress/templates/service-account-pages.yaml @@ -1,4 +1,4 @@ -{{- if .Values.accountPages.enabled }} +{{- if and .Values.service.create .Values.accountPages.enabled }} apiVersion: v1 kind: Service metadata: diff --git a/charts/wire-ingress/templates/service-team-settings.yaml b/charts/wire-ingress/templates/service-team-settings.yaml index 08b1fe6dfb1..6d2900b1cb6 100644 --- a/charts/wire-ingress/templates/service-team-settings.yaml +++ b/charts/wire-ingress/templates/service-team-settings.yaml @@ -1,4 +1,4 @@ -{{- if .Values.teamSettings.enabled }} +{{- if and .Values.service.create .Values.teamSettings.enabled }} apiVersion: v1 kind: Service metadata: diff --git a/charts/wire-ingress/templates/service-webapp.yaml b/charts/wire-ingress/templates/service-webapp.yaml index 7e2d0d496a4..efdccd279fc 100644 --- a/charts/wire-ingress/templates/service-webapp.yaml +++ b/charts/wire-ingress/templates/service-webapp.yaml @@ -1,4 +1,4 @@ -{{- if .Values.webapp.enabled }} +{{- if and .Values.service.create .Values.webapp.enabled }} apiVersion: v1 kind: Service metadata: diff --git a/charts/wire-ingress/values.yaml b/charts/wire-ingress/values.yaml index 0e6e901d266..bcd14ac911b 100644 --- a/charts/wire-ingress/values.yaml +++ b/charts/wire-ingress/values.yaml @@ -1,4 +1,8 @@ # Default values for wire-ingress +httpRoute: + # Annotations added to every HTTPRoute created by this chart. + annotations: {} + gateway: # If true, a Gateway resource is created by this chart. # If false, set gateway.name to reference an existing Gateway. @@ -113,6 +117,48 @@ gateway: # certificateDomain: federator. # domain to use in the federator CSR # teamSettings: teams. # ignored unless teamSettings.enabled == true # accountPages: account. # ignored unless accountPages.enabled == true +# +# MULTI-INGRESS (multiple backend domains from one release) +# --------------------------------------------------------- +# To serve the same backend on several domains, set config.domains INSTEAD of +# config.dns. It is a list; the FIRST entry is the primary (its resources keep +# the un-suffixed names, and its frontend apps set their own CSP). Every +# additional entry gets its own Gateway HTTPS listener (`https-`), its own +# certificate/secret, and — being an "additional ingress" — a per-domain +# Content-Security-Policy header injected on the webapp/team-settings/ +# account-pages routes (mirrors the legacy nginx-ingress-services behaviour). +# +# config.dns and config.domains are mutually exclusive; config.domains wins. +# +# Multi-ingress is ALSO mutually exclusive with federation: you cannot enable +# federator.enabled together with config.domains. Multi-ingress is the special +# case (a single customer serving one backend on several unrelated domains); +# federation is the common case. Pick one — federation with a single backend +# domain via config.dns, or multi-ingress via config.domains with the federator +# disabled. Setting both fails template rendering with a clear error. +# +# config: +# domains: +# - name: blueberry # required; used for resource-name suffix & listener section +# base: blueberry.example.com # required; CSP wildcard (*.base) and default listener hostname (*.base) +# # hostname: "*.blueberry.example.com" # optional listener hostname override (defaults to *.base) +# dns: +# https: nginz-https.blueberry.example.com +# ssl: nginz-ssl.blueberry.example.com +# webapp: webapp.blueberry.example.com +# # teamSettings / accountPages / fakeS3 as needed +# - name: red +# base: red.example.org +# dns: +# https: nginz-https.red.example.org +# ssl: nginz-ssl.red.example.org +# webapp: webapp.red.example.org +# # renderCSP: false # optional: disable the injected CSP for this domain +# tls: +# # secretName: "" # optional TLS secret name override (defaults to a per-domain name) +# issuer: # optional per-domain cert-manager issuer override (defaults to tls.issuer) +# name: letsencrypt-red +# kind: ClusterIssuer websockets: enabled: true @@ -141,6 +187,9 @@ accountPages: enabled: false service: + # Set false to reuse the backend Services owned by nginx-ingress-services + # (e.g. during a migration) instead of creating them here. + create: true webapp: externalPort: 8080 teamSettings: diff --git a/charts/wire-server/templates/background-worker/configmap.yaml b/charts/wire-server/templates/background-worker/configmap.yaml index e7e6f1d2262..d4fe2a63202 100644 --- a/charts/wire-server/templates/background-worker/configmap.yaml +++ b/charts/wire-server/templates/background-worker/configmap.yaml @@ -93,6 +93,22 @@ data: backgroundJobs: {{ toYaml . | indent 6 }} {{- end }} + jobs: + # Arbiter dispatcher poll interval for all jobs. + # Lower values reduce discovery latency, but increase DB polling. + pollInterval: {{ .jobs.pollInterval }} + # Number of worker threads per job queue. + workerThreads: {{ .jobs.workerThreads }} + visibilityTimeout: {{ .jobs.visibilityTimeout }} + jobHeartbeatInterval: {{ .jobs.jobHeartbeatInterval }} + workerHeartbeatInterval: {{ .jobs.workerHeartbeatInterval }} + backoffBase: {{ .jobs.backoffBase }} + backoffCap: {{ .jobs.backoffCap }} + jitter: {{ .jobs.jitter }} + gracefulShutdownTimeout: {{ .jobs.gracefulShutdownTimeout }} + reaperInterval: {{ .jobs.reaperInterval }} + reaperTimeout: {{ .jobs.reaperTimeout }} + workerStaleThreshold: {{ .jobs.workerStaleThreshold }} {{- with .meetingsCleanup }} meetingsCleanup: {{ toYaml . | indent 6 }} diff --git a/charts/wire-server/templates/background-worker/deployment.yaml b/charts/wire-server/templates/background-worker/deployment.yaml index e2f6ef5250e..f91ccb5ee17 100644 --- a/charts/wire-server/templates/background-worker/deployment.yaml +++ b/charts/wire-server/templates/background-worker/deployment.yaml @@ -32,6 +32,7 @@ spec: checksum/galley-secret: {{ include (print .Template.BasePath "/galley/secret.yaml") . | sha256sum }} fluentbit.io/parser: json spec: + terminationGracePeriodSeconds: {{ $backgroundWorker.terminationGracePeriodSeconds }} serviceAccount: null serviceAccountName: null automountServiceAccountToken: false diff --git a/charts/wire-server/templates/cannon/statefulset.yaml b/charts/wire-server/templates/cannon/statefulset.yaml index ecc842b511a..00103604bf8 100644 --- a/charts/wire-server/templates/cannon/statefulset.yaml +++ b/charts/wire-server/templates/cannon/statefulset.yaml @@ -143,7 +143,8 @@ spec: {{- toYaml .Values.cannon.resources | nindent 10 }} initContainers: - name: cannon-configurator - image: alpine:3.21.3 + image: "{{ .Values.cannon.configuratorImage.repository }}:{{ .Values.cannon.configuratorImage.tag }}" + imagePullPolicy: "{{ .Values.cannon.configuratorImage.pullPolicy }}" {{- if eq (include "includeSecurityContext" .) "true" }} securityContext: {{- toYaml .Values.cannon.podSecurityContext | nindent 10 }} diff --git a/charts/wire-server/templates/galley/configmap.yaml b/charts/wire-server/templates/galley/configmap.yaml index 1f274edba0e..5dc3984c6f3 100644 --- a/charts/wire-server/templates/galley/configmap.yaml +++ b/charts/wire-server/templates/galley/configmap.yaml @@ -113,7 +113,37 @@ data: checkGroupInfo: {{ .settings.checkGroupInfo }} {{- end }} meetings: - {{- toYaml .settings.meetings | nindent 8 }} + {{- with .settings.meetings }} + {{- if .validityPeriod }} + validityPeriod: {{ .validityPeriod }} + {{- end }} + legacyTimeZone: {{ .legacyTimeZone }} + {{- if .pastEditPeriod }} + pastEditPeriod: {{ .pastEditPeriod }} + {{- end }} + {{- with .email }} + email: + from: {{ required "Missing value: galley.config.settings.meetings.email.from" .from | quote }} + {{- if .replyTo }} + replyTo: {{ .replyTo | quote }} + {{- end }} + transport: + {{- if .useSES }} + sesQueue: {{ required "Missing value: galley.config.settings.meetings.email.aws.sesQueue" .aws.sesQueue }} + sesEndpoint: {{ .aws.sesEndpoint | quote }} + {{- else }} + smtpEndpoint: + host: {{ .smtp.host }} + port: {{ .smtp.port }} + smtpConnType: {{ .smtp.connType }} + {{- if .smtp.username }} + smtpCredentials: + smtpUsername: {{ .smtp.username }} + smtpPassword: {{ .smtp.passwordFile }} + {{- end }} + {{- end }} + {{- end }} + {{- end }} featureFlags: sso: {{ .settings.featureFlags.sso }} legalhold: {{ .settings.featureFlags.legalhold }} @@ -230,13 +260,5 @@ data: meetings: {{- toYaml .settings.featureFlags.meetings | nindent 10 }} {{- end }} - {{- if .settings.featureFlags.meetingsPremium }} - meetingsPremium: - {{- toYaml .settings.featureFlags.meetingsPremium | nindent 10 }} - {{- end }} - {{- if .settings.featureFlags.backgroundEffects }} - backgroundEffects: - {{- toYaml .settings.featureFlags.backgroundEffects | nindent 10 }} - {{- end }} {{- end }} {{- end }} diff --git a/charts/wire-server/templates/galley/secret.yaml b/charts/wire-server/templates/galley/secret.yaml index 8425394ddd4..beb586c8745 100644 --- a/charts/wire-server/templates/galley/secret.yaml +++ b/charts/wire-server/templates/galley/secret.yaml @@ -27,3 +27,7 @@ data: {{- if .Values.galley.secrets.pgPassword }} pgPassword: {{ .Values.galley.secrets.pgPassword | b64enc | quote }} {{- end }} + + {{- if .Values.galley.secrets.smtpPassword }} + smtp-password.txt: {{ .Values.galley.secrets.smtpPassword | b64enc | quote }} + {{- end }} diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index 48a45ffd5aa..5bb0b276eb6 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -71,7 +71,6 @@ galley: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m # Not used if enableFederation is false @@ -142,6 +141,26 @@ galley: meetings: validityPeriod: "48h" + legacyTimeZone: "Europe/Berlin" + pastEditPeriod: "24h" + # Optional. When set, meeting invitation emails are sent with this + # sender over the configured transport (SES xor SMTP). `useSES` selects + # the transport; `aws` is used when true, `smtp` when false (mirrors + # brig.config.useSES). The ConfigMap renders these into the nested + # `transport:` object that Galley parses. + # email: + # from: meetings@example.com # required when email present + # replyTo: noreply@example.com # optional + # useSES: true + # aws: # used when useSES: true + # sesQueue: wire-meetings-email-feedback + # sesEndpoint: https://email.us-east-1.amazonaws.com + # smtp: # used when useSES: false + # host: smtp.example.com + # port: 587 + # connType: tls + # username: meetings # optional; enables smtpCredentials + # passwordFile: /etc/wire/galley/secrets/smtp-password.txt # To disable proteus for new federated conversations: # federationProtocols: ["mls"] @@ -223,6 +242,8 @@ galley: finaliseRegardlessAfter: null # "2029-10-17T00:00:00.000Z" usersThreshold: 100 clientsThreshold: 100 + # Allow group-wise migration by clients + allowManualMigration: false lockStatus: locked limitedEventFanout: defaults: @@ -239,9 +260,10 @@ galley: allowed_to_open_channels: team-members lockStatus: locked preventAdminlessGroups: + # This feature has known errors. Thus, it must stay disabled for now. defaults: status: disabled - lockStatus: unlocked + lockStatus: locked config: promotionStrategy: alphabetical deletionTimeoutDuration: 7d @@ -324,14 +346,6 @@ galley: defaults: status: disabled lockStatus: locked - meetingsPremium: - defaults: - status: disabled - lockStatus: locked - backgroundEffects: - defaults: - status: disabled - lockStatus: locked aws: region: "eu-west-1" proxy: {} @@ -343,6 +357,10 @@ galley: annotations: {} automountServiceAccountToken: true + # Optional secret keys (see templates/galley/secret.yaml): + # smtpPassword: # mounted at /etc/wire/galley/secrets/smtp-password.txt; + # # consumed via settings.meetings.email.smtp.passwordFile + # # (mirrors brig's smtp.passwordFile) secrets: {} podSecurityContext: @@ -501,6 +519,14 @@ cannon: repository: quay.io/wire/nginz tag: do-not-use pullPolicy: IfNotPresent + # Image for the cannon-configurator initContainer, which only runs a + # single `echo` into a shared volume. Override repository to pull from a + # mirror registry, e.g. my-mirror.example/library/alpine + # renovate: datasource=docker depName=alpine + configuratorImage: + repository: alpine + tag: "3.24.1" + pullPolicy: IfNotPresent config: logLevel: Info logFormat: StructuredJSON @@ -841,13 +867,18 @@ spar: # Optional SHA-1 certificate fingerprint allowlist for IdP descriptor # certificates. Empty list (the default) disables the check. Any non-empty - # list enforces that every cert in an IdP descriptor is present in this + # list enforces that every cert in an IdP descriptor is present in this # list (on create/update and on AuthnResponse). Entries are hex strings; # canonical form is uppercase pairs separated by ':' (e.g. "AA:BB:CC:..."), # but the parser accepts lowercase and omitted separators. # Example: # idpCertFingerprintAllowlist: # - "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD" + # + # WARNING - In a multi-ingress setup, this enables the multi-ingress + # cross-IdP fallback with severe security implications. Refer to the + # documentation of this feature ('Multi-ingress cross-IdP SSO (fallback)') + # to understand what this means. idpCertFingerprintAllowlist: [] logLevel: Info @@ -916,6 +947,9 @@ spar: background-worker: replicaCount: 1 + # Must exceed jobs.gracefulShutdownTimeout so Kubernetes does not + # force-kill the worker before it can finish shutting down. + terminationGracePeriodSeconds: 40 image: repository: quay.io/wire/background-worker tag: do-not-use @@ -968,7 +1002,6 @@ background-worker: postgresqlPool: size: 5 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m # Setting this to `true` will start conversation migration to postgresql. @@ -1008,6 +1041,31 @@ background-worker: # Total attempts, including the first try maxAttempts: 3 + # Job dispatcher configuration. + jobs: + # Arbiter dispatcher poll interval for all jobs. + # Lower values reduce discovery latency, but increase DB polling. + pollInterval: 5s + # Number of worker threads per job queue. + workerThreads: 1 + # How long a claimed job remains invisible while it is processed. + visibilityTimeout: 60s + # How often a running job refreshes its visibility timeout. + jobHeartbeatInterval: 30s + # How often a worker refreshes its own heartbeat. + workerHeartbeatInterval: 10s + # Base and cap for exponential retry backoff. + backoffBase: 2.0 + backoffCap: 86400s + # Retry jitter: none, full, or equal. + jitter: equal + # Maximum time to wait for in-flight jobs during shutdown. + gracefulShutdownTimeout: 30s + # How often the Arbiter reaper runs and how long worker heartbeats remain valid. + reaperInterval: 300s + reaperTimeout: 300s + workerStaleThreshold: 300s + # Meetings cleanup configuration meetingsCleanup: # Delete meetings older than this many hours (48 hours = 2 days) @@ -1132,7 +1190,6 @@ brig: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m emailSMS: diff --git a/deploy/dockerephemeral/docker-compose.yaml b/deploy/dockerephemeral/docker-compose.yaml index f96f794b019..2081edddc21 100644 --- a/deploy/dockerephemeral/docker-compose.yaml +++ b/deploy/dockerephemeral/docker-compose.yaml @@ -290,11 +290,11 @@ services: POSTGRES_PASSWORD: "posty-the-gres" POSTGRES_USER: "wire-server" POSTGRES_DB: "backendA" - command: postgres -c max_connections=50 + command: postgres -c max_connections=150 cassandra: container_name: demo_wire_cassandra - image: cassandra:4.1.10 + image: cassandra:4.1.11 ports: - "127.0.0.1:9042:9042" ulimits: @@ -334,7 +334,7 @@ services: - demo_wire init_vhosts: - image: alpine/curl:3.14 + image: alpine/curl:8.21.0 environment: - RABBITMQ_USERNAME=${RABBITMQ_USERNAME} - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD} diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index ab4bbf32689..cf86ee7681c 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -249,34 +249,100 @@ The lock status for individual teams can be changed via the internal API (`PUT / The feature status for individual teams can be changed via the public API (if the feature is unlocked). -### Meetings Premium +### Meetings validity and past-edit periods -The `meetingsPremium` feature flag controls whether a team has premium meetings features. When enabled, meetings created by team members are not marked as trial. When disabled, meetings are trial and limited to 25 minutes. It is enabled and unlocked by default. If you want a different configuration, use the following syntax: +`settings.meetings.validityPeriod` (default `48h`) is how long a meeting stays +alive (readable and editable) after its effective end time — its `end_time`, or +the end of its recurrence window for recurring meetings; open-ended recurring +meetings never expire. `settings.meetings.pastEditPeriod` (default `24h`) bounds how +far into the past `PUT /meetings/{domain}/{id}` may move a meeting's +`start_time`/`end_time`, so past and ongoing meetings can be corrected to what +actually happened. Only provided time values are checked against this cutoff; +unchanged stored times are not re-validated — but the effective times (provided +or stored) must still satisfy `end_time > start_time`. Galley refuses to start +if `pastEditPeriod` is negative or greater than `validityPeriod`, so a meeting +edited to past times stays inside the validity window and remains visible and editable. ```yaml -meetingsPremium: - defaults: - status: disabled|enabled - lockStatus: locked|unlocked +# galley.yaml +settings: + meetings: + validityPeriod: "48h" + pastEditPeriod: "24h" ``` -The lock status for individual teams can be changed via the internal API (`PUT /i/teams/:tid/features/meetingsPremium/(un)?locked`). - -The feature status for individual teams can be changed via the public API (if the feature is unlocked). +### Meetings email sender and transport -### Background Effects +The optional `settings.meetings.email` block enables emailing meeting +invitations to external addresses. `from` is required; `replyTo` is optional. +`transport` is an AWS SES or SMTP value (the same shape Brig uses for its +email transport). When the block is absent, no meeting invitation emails are +sent. -The `backgroundEffects` feature flag controls whether background effects are available in meetings. It is disabled and locked by default. If you want a different configuration, use the following syntax: ```yaml -backgroundEffects: - defaults: - status: disabled|enabled - lockStatus: locked|unlocked -``` - -The lock status for individual teams can be changed via the internal API (`PUT /i/teams/:tid/features/backgroundEffects/(un)?locked`). - -The feature status for individual teams can be changed via the public API (if the feature is unlocked). +# galley.yaml +settings: + meetings: + email: + from: meetings@example.com + replyTo: noreply@example.com + transport: # SES: + sesQueue: wire-meetings-email-feedback + sesEndpoint: https://email.us-east-1.amazonaws.com + # transport: # SMTP (xor SES): + # smtpEndpoint: { host: smtp.example.com, port: 587 } + # smtpConnType: tls + # smtpCredentials: + # smtpUsername: meetings + # smtpPassword: /etc/wire/galley/secrets/smtp-password.txt +``` + +For SMTP, the Helm value `galley.config.settings.meetings.email.smtp.passwordFile` +points at the path where the SMTP password is read, and +`galley.secrets.smtpPassword` holds the value (mounted at +`/etc/wire/galley/secrets/smtp-password.txt`). The Galley ConfigMap injects that +path into `transport.smtpCredentials.smtpPassword`, the same pattern Brig uses +for `smtp.passwordFile`. + +### Meetings time settings + +The `galley.config.settings.meetings.legacyTimeZone` Helm value is an IANA time +zone id (e.g. `"Europe/Berlin"`, the default) used as the `tzid` for meetings +created by legacy clients (< V17), which send an `end_time` instead of the V17 +`duration` + `tzid` fields. It has no effect on V17+ clients. + +### Meetings Premium (deprecated) + +> **Deprecated (WPB-26771).** The `meetingsPremium` feature flag no longer +> affects meeting behaviour. Team meetings are always non-trial regardless of +> this flag's value. The flag and its data type are retained for backward +> compatibility. + +The flag now defaults to **enabled and locked** and the Helm configuration +override has been removed (operators can no longer change it via Helm). The +`MeetingsPremiumConfig` type carries a `DEPRECATED` pragma. The public +`GET/PUT /teams/:tid/features/meetingsPremium` endpoints and the internal +lock-status endpoints have no behavioural effect and now return 404 at API +version v17; they remain available through v16. + +The aggregate list endpoints (`GET /feature-configs`, +`GET /teams/:tid/features`) continue to include `meetingsPremium` at all API +versions, including v17. + +### Background Effects (deprecated) + +> **Deprecated (WPB-27912).** The `backgroundEffects` feature flag no longer +> affects meeting behaviour. The flag, its data type and its public/internal +> endpoints are retained for backward compatibility and are scheduled for +> removal in a future release. + +The flag now defaults to **enabled and locked** and the Helm configuration +override has been removed (operators can no longer change it via Helm). The +`GET/PUT /teams/:tid/features/backgroundEffects` and internal lock-status +endpoints return 404 at API version v17; they remain available through v16. +The aggregate endpoints `GET /feature-configs` and +`GET /teams/:tid/features` continue to include `backgroundEffects` at all API +versions, including v17. ### File Sharing @@ -326,6 +392,21 @@ The settings mean: - `deletionTimeoutDuration`: how long to keep an adminless conversation before it is deleted. - `reminderTimeoutDurations`: when before deletion reminder notifications should be sent. +In federated conversations, automatic senderless deletion is skipped when the +conversation contains remote members because the corresponding system delete +event cannot yet be sent safely to the remote backend. This applies both when +the feature is enabled and existing conversations are scanned without an +origin user, and when a previously scheduled senderless deletion job runs. +Reminders for a skipped deletion are also skipped because they would be +misleading. The skipped deletion is logged at info level. + +Autopromotion still runs because the conversation-owning backend stores the +authoritative member roles. Remote clients may miss the immediate senderless +member-update notification, but a subsequent conversation fetch obtains the +current role from the owning backend. Member updates and deletions with an +origin user continue to use the existing ordinary federation events and are +not skipped. + Durations are strings with a number and a unit suffix. Supported units are `us`, `ms`, `s`, `m`, `h`, `d`, and `w`. It is **not** recommended or supported to set these below a day in production environments. Feature responses, including `GET /feature-configs`, `GET /teams/:tid/features`, and `GET /teams/:tid/features/preventAdminlessGroups`, include the duration fields: @@ -344,7 +425,7 @@ Feature responses, including `GET /feature-configs`, `GET /teams/:tid/features`, From a client's perspective, API versioning works like this: -- API version V17 and newer should send the duration fields to `PUT /teams/:tid/features/preventAdminlessGroups`. +- API version V18 and newer should send the duration fields to `PUT /teams/:tid/features/preventAdminlessGroups`. - Feature responses include the duration fields for clients to read. The lock status for individual teams can be changed via the internal API (`PUT /i/teams/:tid/features/preventAdminlessGroups/(un)?locked`). @@ -1332,7 +1413,8 @@ Given an email address, the SSO code is looked up by these criteria: - The mapping must be unambiguous (there must be exactly one matching IdP). In multi-ingress mode, IdPs are always bound to one domain; the request domain must match the IdP's configured domain. -- The user was created via SCIM +- The user is a SSO user. So it was created via SCIM with SSO enabled (any IdP + configured in SCIM token) OR created via SSO (no SCIM involved). The last condition ensures that team admins cannot get into locked-out situations due to misconfigured IdPs. @@ -1347,16 +1429,19 @@ configured in `nginz`'s Helm chart in the #### IdP certificate fingerprint allowlist -This optional feature restricts which X.509 certificates can be used in IdP -metadata. When configured, all certificates in IdP descriptors must have a -SHA-1 fingerprint present in the allowlist, or IdP creation/update and SAML +This feature restricts which X.509 certificates can be used in IdP metadata. +When configured, all certificates in IdP descriptors must have a SHA-1 +fingerprint present in the allowlist, or IdP creation/update and SAML AuthnResponse (`/sso/finalize-login`) requests will be rejected. This limits team admins in their choice of IdPs. E.g. a malicious team admin couldn't provision bad IdPs, as possible IdP certificates are restricted by the allowlist. -The feature is disabled by default in Helm (the attribute can be left out as well): +**For multi-ingress setups, this feature is mandatory** to prevent +security-relevant configuration mistakes. For regular (non-multi-ingress) +setups, it is optional and disabled by default in Helm (the attribute can be +left out as well): ```yaml config: @@ -1389,6 +1474,13 @@ All formats must be exactly 40 hex digits (20 bytes). Invalid hex or wrong length (anything not exactly 20 bytes / 40 hex digits) causes the configuration to fail at startup with a clear error message. +!!! danger Multi-ingress cross-IdP SSO fallback + + This feature enables multi-ingress cross-IdP authentication in multi-ingress + scenarios. I.e. multiple IdPs can be used to authenticate the same account. + This can be a security issue if IdPs are not configured for this! See + [Multi-ingress cross-IdP SSO(fallback)](#multi-ingress-cross-idp-sso-fallback). + ### SCIM In Helm: @@ -1610,6 +1702,71 @@ error. Though, IdPs can be reconfigured as long as this invariant holds. Putting it differently: We require an unambiguous mapping `(team, domain) -> IdP`. +For multi-ingress setups, the [`idpCertFingerprintAllowlist`](#idp-certificate-fingerprint-allowlist) +must be configured to restrict which X.509 certificates can be used in IdP metadata. +This restriction was introduced to mitigate risks of +[Multi-ingress cross-IdP SSO (fallback)](#multi-ingress-cross-idp-sso-fallback). + +#### Multi-ingress cross-IdP SSO (fallback) + +Terms used below: + +- _Authenticating IdP_ — the external identity provider that issued the SAML + assertion, identified by the `Issuer` URI inside it. +- _IdP configuration_ — backend's IdP representation registered via + `/identity-providers`, storing the issuer URI, the associated multi-ingress + domain, and the team. + +In the normal SSO flow spar looks up the authenticating user by their `(issuer, +NameID)` pair — matching the assertion's issuer against the IdP configuration +the user was provisioned under. + +In a multi-ingress setup each domain has its own IdP configuration with its own +issuer URI. A user provisioned under domain _A_ has their SSO identity tied to +issuer _A_'s URI. When that user later authenticates via domain _B_, the IdP +authentication response's assertion carries issuer _B_'s URI, so the primary +`(issuer, NameID)` lookup finds nothing. Two IdPs can't have the same Issuer +ID because those must be globally unique, and each external identity provider +controls its own issuer URI (spar can't override it). + +When this primary lookup finds no user, spar therefore attempts a cross-IdP +migration when multi-ingress is configured: + +1. **NameID must be an email address.** Username-based `NameID`s are rejected + to avoid ambiguity across authenticating IdPs. +2. **The matching IdP configuration is resolved.** Spar looks for an IdP + configuration in the team whose issuer URI and configured domain both match + the assertion's issuer and the incoming `Z-Host` header (exact match). + If this condition is unmet, the login is rejected. +3. **Team-wide user search.** The authenticating IdP is now known (step 2), but + the user may still be registered under a *different* team IdP from an + earlier login. Spar therefore searches every IdP configuration in the team, + pairing each one's issuer with the assertion's email NameID, and tries the + primary `(issuer, NameID)` lookup for each pairing until one matches. +4. **Migrate or provision:** + - _Exactly one match found:_ The user's SSO identity is updated to point to + the IdP configuration for the authenticating IdP's issuer, so subsequent + logins hit the primary lookup directly. This saves the complexity of the IdP + configuration lookup and keeps the backend's representations of the user's + SSO data sound. + - _No match found:_ A new user account is auto-provisioned under the + authenticating IdP's configuration. + - _No matching IdP configuration can be resolved:_ Login is rejected. + +##### Security considerations + +It must be ensured that email `NameID`s are unique across IdPs by IdP +administrators. Otherwise, users may be logged into other users' accounts! + +!!! danger + This fallback feature breaks the 1:1 relationship between users and IdPs. To + use it safely, all user accounts must be consistent across all IdPs. Also note, + that this increases the attack surface as you have to secure and maintain + multiple IdPs; while a successful attack on one of them breaks security for all + accounts of a team with that IdP configured. + + **If in doubt, please contact customer support!** + ### Webapp The webapp runs its own web server (a NodeJS server) to serve static files and the webapp config (based on environment variables). @@ -1939,15 +2096,6 @@ config: # Connection acquisition timeout. acquisitionTimeout: 10s - # Maximal connection lifetime. - # - # Determines how long is available for reuse. After the timeout passes and - # an active session is finished the connection will be closed releasing a - # slot in the pool for a fresh connection to be established. - # - # This is useful as a healthy measure for resetting the server-side caches. - agingTimeout: 1d - # Maximal connection idle time. idlenessTimeout: 10m secrets: @@ -1965,7 +2113,6 @@ postgresql: postgresqlPool: size: 100 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m postgresqlPassword: /path/to/pgPassword # refers to a PostgreSQL password file ``` @@ -2194,7 +2341,6 @@ postgresql: postgresqlPool: size: 5 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m # Start migration workers when true @@ -2216,10 +2362,66 @@ backgroundJobs: jobTimeout: 60s # per attempt maxAttempts: 3 # total attempts incl. first run +# Jobs +jobs: + pollInterval: 5s # how often due jobs are discovered + workerThreads: 1 # worker threads for each job queue + visibilityTimeout: 60s # how long a claimed job stays invisible + jobHeartbeatInterval: 30s # refresh interval for running jobs + workerHeartbeatInterval: 10s # refresh interval for worker liveness + backoffBase: 2.0 # exponential retry backoff base + backoffCap: 86400s # maximum exponential retry backoff + jitter: equal # none, full, or equal retry jitter + gracefulShutdownTimeout: 30s # maximum shutdown grace period + reaperInterval: 300s # Arbiter reaper interval + reaperTimeout: 300s # maximum duration of one reaper pass + workerStaleThreshold: 300s # worker heartbeat staleness threshold + +Jobs are currently split into two domain queues: `meetings`, which +contains meeting cleanup jobs, and `conversations`, which contains adminless +reminder and deletion jobs. Each queue has its own Arbiter worker pool. The +`workerThreads` value applies independently to both pools. + +Both worker pools share the configured `postgresqlPool`; no connection is +reserved permanently for an individual queue or worker pool. An active +transactional job temporarily holds one connection, so concurrent jobs can +borrow multiple connections from the shared pool. Short-lived dispatcher, +heartbeat, and reaper operations also borrow connections as needed. For +example, with two queues and `workerThreads: 3`, up to six active job +transactions may need connections concurrently, in addition to these internal +operations. Size `postgresqlPool.size` and PostgreSQL `max_connections` for +the expected concurrency. + +The job runner uses polling rather than LISTEN/NOTIFY. It therefore does not +open a separate listener connection; new jobs are discovered according to +`jobs.pollInterval`. + +`backgroundJobs` and `jobs` configure different job systems. The +`backgroundJobs` consumer receives immediate user-group synchronization jobs +from RabbitMQ and controls their in-process concurrency, timeout, and retry +behavior. `jobs` runs Arbiter-backed PostgreSQL jobs that may be +scheduled for a future time, including recurring jobs, and controls their +dispatcher, worker-pool, visibility, retry, and reaper behavior. The systems +are separate because they currently use different transports and execution +semantics. They could be merged in the future if the user-group jobs are +migrated to Arbiter. + # Required for addressing local vs remote backends federationDomain: example.org ``` +### Job runner PostgreSQL connections + +Background-worker runs Arbiter jobs through its configured PostgreSQL pool. +Choose the pool size and `jobs.workerThreads` to provide sufficient capacity for +the expected job workload, and size PostgreSQL's `max_connections` accordingly. + +At startup, each service that runs the Arbiter migrations briefly opens a +separate PostgreSQL connection to acquire the migration advisory lock. Include +this transient connection in the PostgreSQL connection budget and startup +headroom. It remains open for the migration and related index setup, then is +released and closed after the advisory lock is released. + The `migrationOptions.timeout` setting limits how long a single migration attempt may run after it has acquired the migration lock. If the timeout is exceeded, that migration attempt is aborted and treated as failed. @@ -2241,3 +2443,8 @@ Notes - The `migrate...` flags control the corresponding PostgreSQL backfill jobs for the current migration settings; leave them `false` for new installs and after migration. - `concurrency`, `jobTimeout`, and `maxAttempts` control parallelism and retry behavior of the consumer. - `brig` and `gundeck` endpoints default to in-cluster services; override via `background-worker.config.brig` and `.gundeck` if your service DNS/ports differ. +- `jobs` controls the Arbiter dispatcher, worker, retry, shutdown, and reaper settings. All fields default to the values shown above. +- `jobs.pollInterval` controls how often the background worker wakes up to check for due jobs. +- `jobs.workerThreads` controls the number of worker threads in each job queue. The default is `1`; increasing it allows jobs in that queue to run in parallel when their group keys permit it. +- Both job queues share the same PostgreSQL pool. Increasing `jobs.workerThreads` can increase the number of connections needed when more jobs run concurrently, but it does not create a permanently dedicated connection per thread or queue. +- The job runner is poll-only and does not require an additional PostgreSQL listener connection. diff --git a/docs/src/understand/associate/deeplink.md b/docs/src/understand/associate/deeplink.md index 9eb00b304db..0a8a65b0a1b 100644 --- a/docs/src/understand/associate/deeplink.md +++ b/docs/src/understand/associate/deeplink.md @@ -80,6 +80,10 @@ Note on the meaning of the URLs used below: : Arbitrary string that may show up in a few places in the app. Should be used as an identifier of the backend servers in question. +`supportEmail` (optional) + +: An email address used by clients for support-related contact actions. If it is not configured, the `supportEmail` key is omitted from `deeplink.json`. + ### With Added Proxy `apiProxy:host (optional)` @@ -112,6 +116,7 @@ nginz: accountsURL: "https://account.example.com" blackListURL: "https://clientblacklist.example.com/prod" websiteURL: "https://example.com" + supportEmail: "support@example.com" # (optional) apiProxy: # (optional) host: "socks5.proxy.com" port: 1080 @@ -160,6 +165,7 @@ nginz: teamsURL: "https://teams.red.example.com" accountsURL: "https://account.red.example.com" websiteURL: "https://red.example.com" + supportEmail: "support@red.example.com" # (optional) title: "Production red.example.com" apiProxy: # (optional) host: "socks5.proxy.com" diff --git a/flake.lock b/flake.lock index ae1e0b1c673..bd8e74f6d3a 100644 --- a/flake.lock +++ b/flake.lock @@ -16,6 +16,23 @@ "type": "github" } }, + "arbiter": { + "flake": false, + "locked": { + "lastModified": 1785161366, + "narHash": "sha256-qNZX0LWa+78C3j37fPYaJ0VZOGnkmAEo1Qpa0J6O6gY=", + "owner": "velveteer", + "repo": "arbiter", + "rev": "b9c57eb1f8277d97616aa449bea471fe9ce14eda", + "type": "github" + }, + "original": { + "owner": "velveteer", + "repo": "arbiter", + "rev": "b9c57eb1f8277d97616aa449bea471fe9ce14eda", + "type": "github" + } + }, "bloodhound": { "flake": false, "locked": { @@ -86,11 +103,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1746162366, - "narHash": "sha256-5SSSZ/oQkwfcAz/o/6TlejlVGqeK08wyREBQ5qFFPhM=", + "lastModified": 1761640442, + "narHash": "sha256-AtrEP6Jmdvrqiv4x2xa5mrtaIp3OEe8uBYCDZDS+hu8=", "owner": "nix-community", "repo": "flake-compat", - "rev": "0f158086a2ecdbb138cd0429410e44994f1b7e4b", + "rev": "4a56054d8ffc173222d09dad23adf4ba946c8884", "type": "github" }, "original": { @@ -104,11 +121,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1756770412, - "narHash": "sha256-+uWLQZccFHwqpGqr2Yt5VsW/PbeJVTn9Dk6SHWhNRPw=", + "lastModified": 1775087534, + "narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "4524271976b625a4a605beefd893f270620fd751", + "rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b", "type": "github" }, "original": { @@ -163,11 +180,11 @@ ] }, "locked": { - "lastModified": 1757239681, - "narHash": "sha256-E9spYi9lxm2f1zWQLQ7xQt8Xs2nWgr1T4QM7ZjLFphM=", + "lastModified": 1776796298, + "narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=", "owner": "cachix", "repo": "git-hooks.nix", - "rev": "ab82ab08d6bf74085bd328de2a8722c12d97bd9d", + "rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad", "type": "github" }, "original": { @@ -215,6 +232,23 @@ "type": "github" } }, + "hasql-resource-pool": { + "flake": false, + "locked": { + "lastModified": 1783610992, + "narHash": "sha256-xpnT7V2EddKVzK5cvaPeRYpCyRbWyqkQJrsBMpSbWXg=", + "owner": "wireapp", + "repo": "hasql-resource-pool", + "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", + "type": "github" + }, + "original": { + "owner": "wireapp", + "repo": "hasql-resource-pool", + "rev": "5b5d3df0fff81801986a0110acae5420215f01c5", + "type": "github" + } + }, "hsaml2": { "flake": false, "locked": { @@ -284,11 +318,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1754788789, - "narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=", + "lastModified": 1774748309, + "narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "a73b9c743612e4244d865a2fdee11865283c04e6", + "rev": "333c4e0545a6da976206c74db8773a1645b5870a", "type": "github" }, "original": { @@ -350,12 +384,14 @@ "root": { "inputs": { "amazonka": "amazonka", + "arbiter": "arbiter", "bloodhound": "bloodhound", "cql": "cql", "cql-io": "cql-io", "cryptostore": "cryptostore", "flake-utils": "flake-utils", "hasql-migration": "hasql-migration", + "hasql-resource-pool": "hasql-resource-pool", "hsaml2": "hsaml2", "hspec-wai": "hspec-wai", "http-client": "http-client", @@ -382,19 +418,19 @@ "nixpkgs": [ "nixpkgs" ], - "treefmt-nix": "treefmt-nix" + "vulnix": "vulnix" }, "locked": { - "lastModified": 1760339225, - "narHash": "sha256-pYZax5cxBHa+jcxTsQKEVHhXMtmvLGD1ISUyli2ZreU=", + "lastModified": 1780995381, + "narHash": "sha256-q91Amk0tTbACseVLmvd3LhBixcACyskVHD7lo1mSf28=", "owner": "tiiuae", "repo": "sbomnix", - "rev": "fe2a608c000127092b3d27c9cfd65c26f325bb28", + "rev": "30c95eb2fc0c402300ee05354c5cf84c7e5f74e2", "type": "github" }, "original": { "owner": "tiiuae", - "ref": "v1.7.4", + "ref": "v1.8.0", "repo": "sbomnix", "type": "github" } @@ -526,15 +562,16 @@ "inputs": { "nixpkgs": [ "sbomnix", + "vulnix", "nixpkgs" ] }, "locked": { - "lastModified": 1756662192, - "narHash": "sha256-F1oFfV51AE259I85av+MAia221XwMHCOtZCMcZLK2Jk=", + "lastModified": 1775636079, + "narHash": "sha256-pc20NRoMdiar8oPQceQT47UUZMBTiMdUuWrYu2obUP0=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "1aabc6c05ccbcbf4a635fb7a90400e44282f61c4", + "rev": "790751ff7fd3801feeaf96d7dc416a8d581265ba", "type": "github" }, "original": { @@ -543,6 +580,41 @@ "type": "github" } }, + "vulnix": { + "inputs": { + "flake-compat": [ + "sbomnix", + "flake-compat" + ], + "flake-parts": [ + "sbomnix", + "flake-parts" + ], + "flake-root": [ + "sbomnix", + "flake-root" + ], + "nixpkgs": [ + "sbomnix", + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1778651554, + "narHash": "sha256-QxkkaBVdIIot/YVxPuzUhjP7cDAp7U9iJXWozVTcuww=", + "owner": "nix-community", + "repo": "vulnix", + "rev": "038b06e335c41d7d2dcbccbf4f821f95d7c17b16", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "master", + "repo": "vulnix", + "type": "github" + } + }, "wai-predicates": { "flake": false, "locked": { diff --git a/flake.nix b/flake.nix index bbc3c8826f9..09cc084bba4 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ inputs.flake-utils.follows = "flake-utils"; }; sbomnix = { - url = "github:tiiuae/sbomnix/v1.7.4"; + url = "github:tiiuae/sbomnix/v1.8.0"; inputs.nixpkgs.follows = "nixpkgs"; }; @@ -101,6 +101,18 @@ url = "github:wireapp/hsaml2/use-crypton-asn1"; flake = false; }; + + hasql-resource-pool = { + # Update this to the upstream repo/rev once the PR is merged there. + # https://github.com/avanov/hasql-resource-pool/pull/6 + url = "github:wireapp/hasql-resource-pool?rev=5b5d3df0fff81801986a0110acae5420215f01c5"; + flake = false; + }; + + arbiter = { + url = "github:velveteer/arbiter?rev=b9c57eb1f8277d97616aa449bea471fe9ce14eda"; + flake = false; + }; }; outputs = inputs@{ nixpkgs, nixpkgs_24_11, flake-utils, tom-bombadil, sbomnix, ... }: diff --git a/hack/bin/headroom-treefmt.sh b/hack/bin/headroom-treefmt.sh new file mode 100755 index 00000000000..6f31cf343f5 --- /dev/null +++ b/hack/bin/headroom-treefmt.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# treefmt adapter for headroom (see treefmt.toml and .headroom.yaml). +# +# treefmt hands us a list of file paths, headroom wants them as repeated +# '-s' arguments. Everything else (templates, variables, run-mode) comes +# from .headroom.yaml, which headroom picks up from the working directory +# (treefmt runs formatters from the root of the tree). + +set -euo pipefail + +[[ $# -eq 0 ]] && exit 0 + +args=() +for file in "$@"; do + args+=(-s "$file") +done + +# '-a' matches 'run-mode: add' from the config; we pass it explicitly so that +# this stays add-only even if someone changes the config's default run-mode. +headroom run -a "${args[@]}" diff --git a/hack/bin/set-chart-image-version.sh b/hack/bin/set-chart-image-version.sh index f975d00eb6a..f66bbdf1821 100755 --- a/hack/bin/set-chart-image-version.sh +++ b/hack/bin/set-chart-image-version.sh @@ -12,6 +12,14 @@ do if [[ "$chart" == "nginz" ]]; then # nginz has a different docker tag indentation sed -i "s/^ tag: .*/ tag: $docker_tag/g" "$CHARTS_DIR/$chart/values.yaml" +elif [[ "$chart" == "wire-server" ]]; then + # Anchored to quay.io/wire/ repository: lines so non-wire images (cannon's + # alpine configuratorImage) keep their own tag instead of being stamped. + sed -i -E "/^[[:space:]]*repository: quay\.io\/wire\//{n; s/^([[:space:]]*)tag: .*/\1tag: $docker_tag/}" "$CHARTS_DIR/$chart/values.yaml" +elif [[ "$chart" == "cassandra-migrations" ]]; then + # cassandra-migrations shares one images.tag with no adjacent repository: + # line, so anchor to the images: block to avoid stamping jobDoneImage. + sed -i -E "/^images:/,/^[^[:space:]]/ s/^ tag: .*/ tag: $docker_tag/" "$CHARTS_DIR/$chart/values.yaml" else sed -i "s/^ tag: .*/ tag: $docker_tag/g" "$CHARTS_DIR/$chart/values.yaml" fi diff --git a/hack/bin/set-wire-server-image-version.sh b/hack/bin/set-wire-server-image-version.sh index 98ecf4f30af..7c924909d41 100755 --- a/hack/bin/set-wire-server-image-version.sh +++ b/hack/bin/set-wire-server-image-version.sh @@ -11,7 +11,14 @@ charts=(proxy cassandra-migrations elasticsearch-index federator backoffice inte for chart in "${charts[@]}"; do values_file="$CHARTS_DIR/$chart/values.yaml" if [[ -f "$values_file" ]]; then - sed -i "s/^ tag: .*/ tag: $target_version/g" "$values_file" + if [[ "$chart" == "cassandra-migrations" ]]; then + # cassandra-migrations shares one images.tag across several images and + # has no adjacent repository: line, so anchor to the images: block to + # avoid stamping the (non-wire) jobDoneImage tag. + sed -i -E "/^images:/,/^[^[:space:]]/ s/^ tag: .*/ tag: $target_version/" "$values_file" + else + sed -i "s/^ tag: .*/ tag: $target_version/g" "$values_file" + fi fi done @@ -19,4 +26,6 @@ done sed -i "s/^ tag: .*/ tag: $target_version/g" "$CHARTS_DIR/nginz/values.yaml" # Brig, Galley, Cargohold, BackgroundWorker, Cannon, Gundeck, and Spar are inlined into the umbrella chart. -sed -i "s/^ tag: .*/ tag: $target_version/g" "$CHARTS_DIR/wire-server/values.yaml" +# Anchored to quay.io/wire/ repository: lines so non-wire images (cannon's alpine +# configuratorImage) keep their own tag instead of being stamped. +sed -i -E "/^[[:space:]]*repository: quay\.io\/wire\//{n; s/^([[:space:]]*)tag: .*/\1tag: $target_version/}" "$CHARTS_DIR/wire-server/values.yaml" diff --git a/hack/helm_vars/bitnami.yaml b/hack/helm_vars/wire-image-mirror.yaml similarity index 81% rename from hack/helm_vars/bitnami.yaml rename to hack/helm_vars/wire-image-mirror.yaml index 6b7db99570f..4ec1bce32be 100644 --- a/hack/helm_vars/bitnami.yaml +++ b/hack/helm_vars/wire-image-mirror.yaml @@ -1,6 +1,6 @@ global: # The default is to use docker hub, which has very strict rate limiting. This # comes in the way of testing, specially when running flake-news. - imageRegistry: public.ecr.aws + imageRegistry: quay.io/wire/mirror-images security: allowInsecureImages: true diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index d5b4c7ad883..b0309380b56 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -317,6 +317,15 @@ galley: disabledAPIVersions: [] meetings: validityPeriod: "5s" + legacyTimeZone: "Europe/Berlin" + pastEditPeriod: "5s" + email: + from: meetings@example.com + replyTo: noreply@example.com + useSES: true + aws: + sesQueue: integration-galley-meetings-events + sesEndpoint: http://fake-aws-ses:4569 # These values are insecure, against anyone getting hold of the hash, # but its not a concern for the integration tests. @@ -411,10 +420,17 @@ galley: defaults: status: enabled lockStatus: unlocked - meetingsPremium: + preventAdminlessGroups: + # FUTUREWORK: This feature should be unlocked in the main + # `values.yaml` file. It is just disabled & locked there due to open + # bugs. defaults: status: disabled - lockStatus: locked + lockStatus: unlocked + config: + promotionStrategy: alphabetical + deletionTimeoutDuration: 7d + reminderTimeoutDurations: [2d, 4d, 6d] journal: endpoint: http://fake-aws-sqs:4568 queueName: integration-team-events.fifo @@ -632,6 +648,8 @@ federator: background-worker: replicaCount: 1 + # Integration backends do not need graceful job shutdown + terminationGracePeriodSeconds: 0 resources: requests: {} imagePullPolicy: {{ .Values.imagePullPolicy }} @@ -650,6 +668,19 @@ background-worker: concurrency: 8 jobTimeout: 60s maxAttempts: 3 + jobs: + pollInterval: 1s # Poll every second so due jobs are discovered promptly in tests + workerThreads: 1 + visibilityTimeout: 60s + jobHeartbeatInterval: 30s + workerHeartbeatInterval: 10s + backoffBase: 2.0 + backoffCap: 86400s + jitter: equal + gracefulShutdownTimeout: 30s + reaperInterval: 300s + reaperTimeout: 300s + workerStaleThreshold: 300s meetingsCleanup: cleanOlderThanHours: 0.0014 batchSize: 100 diff --git a/hack/helmfile.yaml.gotmpl b/hack/helmfile.yaml.gotmpl index bb1bedad9bc..09c825f9c31 100644 --- a/hack/helmfile.yaml.gotmpl +++ b/hack/helmfile.yaml.gotmpl @@ -118,7 +118,7 @@ releases: namespace: '{{ .Values.namespace1 }}' chart: '../.local/charts/redis-ephemeral' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/redis-ephemeral/values.yaml' needs: - certs @@ -151,7 +151,7 @@ releases: namespace: '{{ .Values.namespace2 }}' chart: '../.local/charts/redis-ephemeral' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/redis-ephemeral/values.yaml' needs: - certs @@ -164,8 +164,10 @@ releases: namespace: "{{ .Values.namespace1 }}" chart: "bitnami/postgresql" values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/postgresql/values.yaml.gotmpl' + - image: + repository: postgresql - primary: initdb: scripts: @@ -184,8 +186,10 @@ releases: namespace: "{{ .Values.namespace2 }}" chart: "bitnami/postgresql" values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/postgresql/values.yaml.gotmpl' + - image: + repository: postgresql - primary: initdb: scripts: @@ -253,16 +257,20 @@ releases: chart: 'bitnami/rabbitmq' version: '16.0.14' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/rabbitmq/values.yaml.gotmpl' + - image: + repository: rabbitmq - name: 'rabbitmq' namespace: '{{ .Values.namespace2 }}' chart: 'bitnami/rabbitmq' version: '16.0.14' values: - - './helm_vars/bitnami.yaml' + - './helm_vars/wire-image-mirror.yaml' - './helm_vars/rabbitmq/values.yaml.gotmpl' + - image: + repository: rabbitmq - name: 'ingress' namespace: '{{ .Values.namespace1 }}' diff --git a/hack/python/wire/frozendict.py b/hack/python/wire/frozendict.py index 58c6a9939ec..22c9a3ba741 100644 --- a/hack/python/wire/frozendict.py +++ b/hack/python/wire/frozendict.py @@ -1,3 +1,7 @@ +import functools + + +@functools.total_ordering class frozendict(dict): def __init__(self, data): super().__init__(data) diff --git a/integration/default.nix b/integration/default.nix index 8ef7ea4b4c6..8290943dee2 100644 --- a/integration/default.nix +++ b/integration/default.nix @@ -17,6 +17,7 @@ , bytestring-conversion , Cabal , case-insensitive +, cborg , containers , cookie , cql @@ -123,6 +124,7 @@ mkDerivation { bytestring bytestring-conversion case-insensitive + cborg containers cookie cql diff --git a/integration/integration.cabal b/integration/integration.cabal index 92abb7662b7..d124256a118 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -197,6 +197,7 @@ library Test.NginxZAuthModule Test.Notifications Test.OAuth + Test.One2OneTeamConv Test.PasswordReset Test.Presence Test.Property @@ -211,6 +212,7 @@ library Test.Spar Test.Spar.CertFingerprintAllowlist Test.Spar.GetByEmail + Test.Spar.MultiIngressCrossIdpSso Test.Spar.MultiIngressIdp Test.Spar.MultiIngressSSO Test.Spar.STM @@ -261,6 +263,7 @@ library , bytestring , bytestring-conversion , case-insensitive + , cborg , containers , cookie , cql diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index 70e74fce45e..7614567b84f 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -263,6 +263,17 @@ listUsers usr qualifiedUserIds = do req <- baseRequest usr Brig Versioned $ joinHttpPath ["list-users"] submit "POST" (req & addJSONObject ["qualified_ids" .= qUsers]) +listUsersWithContactStatus :: (HasCallStack, MakesValue user, MakesValue qualifiedUserIds) => user -> [qualifiedUserIds] -> App Response +listUsersWithContactStatus usr qualifiedUserIds = do + qUsers <- mapM objQidObject qualifiedUserIds + req <- baseRequest usr Brig Versioned $ joinHttpPath ["list-users"] + submit + "POST" + ( req + & addQueryParams [("include-contact-status", "true")] + & addJSONObject ["qualified_ids" .= qUsers] + ) + data SearchContactsCfg = SearchContactsCfg { user :: Value, searchTerm :: String, @@ -1218,22 +1229,6 @@ removeUserFromGroup user gid uid = do req <- baseRequest user Brig Versioned $ joinHttpPath ["user-groups", gid, "users", uid] submit "DELETE" req -addTeamCollaborator :: (MakesValue owner, MakesValue collaborator, HasCallStack) => owner -> String -> collaborator -> [String] -> App Response -addTeamCollaborator owner tid collaborator permissions = do - req <- baseRequest owner Brig Versioned $ joinHttpPath ["teams", tid, "collaborators"] - (_, collabId) <- objQid collaborator - submit "POST" $ - req - & addJSONObject - [ "user" .= collabId, - "permissions" .= permissions - ] - -getAllTeamCollaborators :: (MakesValue owner) => owner -> String -> App Response -getAllTeamCollaborators owner tid = do - req <- baseRequest owner Brig Versioned $ joinHttpPath ["teams", tid, "collaborators"] - submit "GET" req - data NewApp = NewApp { name :: String, assets :: Maybe [Value], diff --git a/integration/test/API/BrigCommon.hs b/integration/test/API/BrigCommon.hs index cda7084d5ff..4b339314e02 100644 --- a/integration/test/API/BrigCommon.hs +++ b/integration/test/API/BrigCommon.hs @@ -20,6 +20,7 @@ module API.BrigCommon where import API.Common import Data.Aeson.Types (Pair) import Data.Maybe +import Testlib.Prekeys import Testlib.Prelude as Prelude data AddClient = AddClient diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index 4a0051e8907..4407e1a043b 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -76,6 +76,16 @@ getUsersId domain ids = do req <- baseRequest domain Brig Unversioned "/i/users" submit "GET" $ req & addQueryParams [("ids", intercalate "," ids)] +getUsersIdIncludingPending :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response +getUsersIdIncludingPending domain ids = do + req <- baseRequest domain Brig Unversioned "/i/users" + submit "GET" $ + req + & addQueryParams + [ ("ids", intercalate "," ids), + ("includePendingInvitations", "true") + ] + getUsersByEmail :: (HasCallStack, MakesValue domain) => domain -> [String] -> App Response getUsersByEmail domain emails = do req <- baseRequest domain Brig Unversioned "/i/users" diff --git a/integration/test/API/Common.hs b/integration/test/API/Common.hs index a22f489c9f0..cd4b4b348ab 100644 --- a/integration/test/API/Common.hs +++ b/integration/test/API/Common.hs @@ -76,6 +76,15 @@ randomBytes n = liftIO $ BS.pack <$> replicateM n randomIO randomString :: Int -> App String randomString n = liftIO $ replicateM n randomIO +-- Transport-safe alphabet ([A-Za-z0-9]): use for values sent both as a JSON +-- body and a URL query param. Full-range 'randomString' can emit surrogates +-- that 'Text.pack' and percent-encoding map differently, breaking round-trips. +randomAlphaString :: Int -> App String +randomAlphaString n = liftIO $ replicateM n pick + where + chars = mkArray $ ['A' .. 'Z'] <> ['a' .. 'z'] <> ['0' .. '9'] + pick = (chars !) <$> randomRIO (Array.bounds chars) + randomJSON :: App Value randomJSON = do let maxThings = 5 diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs index 658b6275c59..1f9458c8844 100644 --- a/integration/test/API/Galley.hs +++ b/integration/test/API/Galley.hs @@ -82,11 +82,18 @@ defConv :: ConversationProtocol -> CreateConv defConv ConversationProtocolProteus = defProteus defConv ConversationProtocolMLS = defMLS +allowAll :: CreateConv -> CreateConv +allowAll cc = + cc + { access = Just ["code", "link", "invite"], + accessRole = Just ["team_member", "guest", "non_team_member", "service"] + } + allowGuests :: CreateConv -> CreateConv allowGuests cc = cc { access = Just ["code"], - accessRole = Just ["team_member", "guest"] + accessRole = Just ["team_member", "non_team_member", "service"] } instance MakesValue CreateConv where @@ -102,7 +109,7 @@ instance MakesValue CreateConv where <> catMaybes [ "name" .=? cc.name, "access" .=? cc.access, - "access_role_v2" .=? cc.access, + "access_role" .=? cc.accessRole, "team" .=? (cc.team <&> \tid -> Aeson.object ["teamid" .= tid, "managed" .= False]), "message_timer" .=? cc.messageTimer, "receipt_mode" .=? cc.receiptMode, @@ -304,6 +311,11 @@ listConversationsVersioned version user cnvs = do $ req & addJSONObject ["qualified_ids" .= cnvs] +getConversationsVersioned :: (MakesValue user) => Versioned -> user -> String -> App Response +getConversationsVersioned version user ids = do + req <- baseRequest user Galley version "/conversations" + submit "GET" $ addQueryParams [("ids", ids)] req + getMLSPublicKeys :: (HasCallStack, MakesValue user) => user -> App Response getMLSPublicKeys user = do req <- baseRequest user Galley Versioned "/mls/public-keys" @@ -588,6 +600,19 @@ getJoinCodeConv u k v = do req <- baseRequest u Galley Versioned (joinHttpPath ["conversations", "join"]) submit "GET" (req & addQueryParams [("key", k), ("code", v)]) +postJoinCodeConv :: (HasCallStack, MakesValue user) => user -> String -> String -> App Response +postJoinCodeConv u k v = do + req <- baseRequest u Galley Versioned (joinHttpPath ["conversations", "join"]) + submit + "POST" + ( req + & zType "access" + & addJSONObject + [ "key" .= k, + "code" .= v + ] + ) + -- https://staging-nginz-https.zinfra.io/v5/api/swagger-ui/#/default/put_conversations__cnv_domain___cnv__name changeConversationName :: (HasCallStack, MakesValue user, MakesValue conv, MakesValue name) => @@ -967,6 +992,22 @@ resetConversation user groupId epoch = do let payload = object ["group_id" .= groupId, "epoch" .= epoch] submit "POST" $ req & addJSON payload +addTeamCollaborator :: (MakesValue owner, MakesValue collaborator, HasCallStack) => owner -> String -> collaborator -> [String] -> App Response +addTeamCollaborator owner tid collaborator permissions = do + req <- baseRequest owner Galley Versioned $ joinHttpPath ["teams", tid, "collaborators"] + (_, collabId) <- objQid collaborator + submit "POST" + $ req + & addJSONObject + [ "user" .= collabId, + "permissions" .= permissions + ] + +getAllTeamCollaborators :: (MakesValue owner) => owner -> String -> App Response +getAllTeamCollaborators owner tid = do + req <- baseRequest owner Galley Versioned $ joinHttpPath ["teams", tid, "collaborators"] + submit "GET" req + updateTeamCollaborator :: (MakesValue owner, MakesValue collaborator, HasCallStack) => owner -> String -> collaborator -> [String] -> App Response updateTeamCollaborator owner tid collaborator permissions = do (_, collabId) <- objQid collaborator @@ -1061,3 +1102,13 @@ putMeetingInvitation :: (HasCallStack, MakesValue user) => user -> String -> Str putMeetingInvitation user domain meetingId invitation = do req <- baseRequest user Galley Versioned (joinHttpPath ["meetings", domain, meetingId, "invitations"]) submit "PUT" $ req & addJSON invitation + +postMeetingsV16 :: (HasCallStack, MakesValue user) => user -> Value -> App Response +postMeetingsV16 user newMeeting = do + req <- baseRequest user Galley (ExplicitVersion 16) "/meetings" + submit "POST" $ req & addJSON newMeeting + +getMeetingV16 :: (HasCallStack, MakesValue user) => user -> String -> String -> App Response +getMeetingV16 user domain meetingId = do + req <- baseRequest user Galley (ExplicitVersion 16) (joinHttpPath ["meetings", domain, meetingId]) + submit "GET" req diff --git a/integration/test/API/GalleyInternal.hs b/integration/test/API/GalleyInternal.hs index e6354cf1959..5df6c94d549 100644 --- a/integration/test/API/GalleyInternal.hs +++ b/integration/test/API/GalleyInternal.hs @@ -85,6 +85,21 @@ getFederationStatus user domains = $ req & addJSONObject ["domains" .= domainList] +-- | Poll until the user's backend is fully connected to all given domains +assertFullyConnected :: + ( HasCallStack, + MakesValue user + ) => + user -> + [String] -> + App () +assertFullyConnected user domains = + eventually + $ bindResponse (getFederationStatus user domains) + $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "status" `shouldMatch` "fully-connected" + -- | https://staging-nginz-https.zinfra.io/api-internal/swagger-ui/galley/#/galley/put_i_legalhold_whitelisted_teams__tid_ legalholdWhitelistTeam :: (HasCallStack, MakesValue uid, MakesValue tid) => tid -> uid -> App Response legalholdWhitelistTeam tid uid = do diff --git a/integration/test/API/Spar.hs b/integration/test/API/Spar.hs index b679e437256..003ca6afb49 100644 --- a/integration/test/API/Spar.hs +++ b/integration/test/API/Spar.hs @@ -112,6 +112,20 @@ updateScimUser domain scimToken userId scimUser = do & scimCommonHeaders scimToken & addJSON body +patchScimUser :: + (HasCallStack, MakesValue domain, MakesValue patchOp) => + domain -> + String -> + String -> + patchOp -> + App Response +patchScimUser domain scimToken userId patchOp = do + req <- baseRequest domain Spar Versioned $ joinHttpPath ["scim", "v2", "Users", userId] + body <- make patchOp + submit "PATCH" $ req + & scimCommonHeaders scimToken + & addJSON body + createScimUserGroup :: (HasCallStack, MakesValue domain, MakesValue scimUserGroup) => domain -> String -> scimUserGroup -> App Response createScimUserGroup domain token scimUserGroup = do req <- baseRequest domain Spar Versioned "/scim/v2/Groups" diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs index 67281da2d6e..144b9902870 100644 --- a/integration/test/Notifications.hs +++ b/integration/test/Notifications.hs @@ -173,6 +173,9 @@ isConvNameChangeNotif n = fieldEquals n "payload.0.type" "conversation.rename" isMemberUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool isMemberUpdateNotif n = fieldEquals n "payload.0.type" "conversation.member-update" +isConvSystemMemberUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool +isConvSystemMemberUpdateNotif n = fieldEquals n "payload.0.type" "conversation.system.member-update" + isReceiptModeUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool isReceiptModeUpdateNotif n = fieldEquals n "payload.0.type" "conversation.receipt-mode-update" @@ -196,6 +199,26 @@ isConvCreateMeetingNotif :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateMeetingNotif n = fieldEquals n "payload.0.type" "conversation.create-meeting" +isConvDeleteMeetingNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isConvDeleteMeetingNotif n = + fieldEquals n "payload.0.type" "conversation.delete-meeting" + +isMeetingCreateNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingCreateNotif n = + fieldEquals n "payload.0.type" "meeting.create" + +isMeetingMemberAddNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingMemberAddNotif n = + fieldEquals n "payload.0.type" "meeting.member-add" + +isMeetingUpdateNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingUpdateNotif n = + fieldEquals n "payload.0.type" "meeting.update" + +isMeetingDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isMeetingDeleteNotif n = + fieldEquals n "payload.0.type" "meeting.delete" + -- | like 'isConvCreateNotif' but excludes self conversations isConvCreateNotifNotSelf :: (HasCallStack, MakesValue a) => a -> App Bool isConvCreateNotifNotSelf n = @@ -203,7 +226,20 @@ isConvCreateNotifNotSelf n = &&~ do not <$> fieldEquals n "payload.0.data.access" ["private"] isConvDeleteNotif :: (HasCallStack, MakesValue a) => a -> App Bool -isConvDeleteNotif n = fieldEquals n "payload.0.type" "conversation.delete" +isConvDeleteNotif n = + fieldEquals n "payload.0.type" "conversation.delete" + ||~ fieldEquals n "payload.0.type" "conversation.system.delete" + +isNotifTeamConvDelete :: (HasCallStack, MakesValue conv, MakesValue a) => conv -> a -> App Bool +isNotifTeamConvDelete conv n = + isNotifConv conv n + &&~ fieldEquals n "payload.0.type" "conversation.delete" + +isConvAdminlessReminderNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isConvAdminlessReminderNotif n = fieldEquals n "payload.0.type" "conversation.adminless-reminder" + +isConvSystemAdminlessReminderNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isConvSystemAdminlessReminderNotif n = fieldEquals n "payload.0.type" "conversation.system.adminless-reminder" notifTypeIsEqual :: (HasCallStack, MakesValue a) => String -> a -> App Bool notifTypeIsEqual typ n = nPayload n %. "type" `isEqual` typ diff --git a/integration/test/SetupHelpers.hs b/integration/test/SetupHelpers.hs index eedfa53c8c9..f4ed0509567 100644 --- a/integration/test/SetupHelpers.hs +++ b/integration/test/SetupHelpers.hs @@ -31,12 +31,15 @@ import Crypto.Random (getRandomBytes) import Data.Aeson hiding ((.=)) import qualified Data.Aeson as Aeson import qualified Data.ByteString.Base16 as Base16 +import qualified Data.ByteString.Base64 as Base64 import qualified Data.ByteString.Base64.Lazy as EL import qualified Data.ByteString.Base64.URL as B64Url import Data.ByteString.Char8 (unpack) import qualified Data.CaseInsensitive as CI import Data.Default +import Data.Either.Extra (fromRight') import Data.Function +import qualified Data.List.NonEmpty as NonEmpty import Data.String.Conversions (cs) import qualified Data.Text as Text import Data.Text.Encoding (decodeUtf8) @@ -44,6 +47,7 @@ import qualified Data.UUID as UUID import Data.UUID.V1 (nextUUID) import Data.UUID.V4 (nextRandom) import Data.Vector (fromList) +import qualified Data.X509 as X509 import GHC.Stack import qualified SAML2.WebSSO as SAML import qualified SAML2.WebSSO.API.Example as SAML @@ -235,23 +239,34 @@ createMLSOne2OnePartner :: user -> convDomain -> App Value -createMLSOne2OnePartner domain other convDomain = loop +createMLSOne2OnePartner domain other convDomain = do + desiredConvDomain <- make convDomain & asString + go (0 :: Int) desiredConvDomain where - loop = do - u <- randomUser domain def - connectTwoUsers u other - apiVersion <- getAPIVersionFor domain - conv <- - if apiVersion < 6 - then getMLSOne2OneConversation other u >>= getJSON 200 - else getMLSOne2OneConversation other u >>= getJSON 200 >>= (%. "conversation") - - desiredConvDomain <- make convDomain & asString - actualConvDomain <- conv %. "qualified_id.domain" & asString - - if desiredConvDomain == actualConvDomain - then pure u - else loop + maxAttempts = 128 + go n desiredConvDomain + | n >= maxAttempts = + assertFailure $ + "createMLSOne2OnePartner: gave up after " + <> show maxAttempts + <> " attempts to place the 1-1 conversation on domain " + <> desiredConvDomain + | otherwise = do + u <- randomUser domain def + connectTwoUsers u other + apiVersion <- getAPIVersionFor domain + conv <- + if apiVersion < 6 + then getMLSOne2OneConversation other u >>= getJSON 200 + else getMLSOne2OneConversation other u >>= getJSON 200 >>= (%. "conversation") + + actualConvDomain <- conv %. "qualified_id.domain" & asString + + if desiredConvDomain == actualConvDomain + then pure u + else do + putConnection other u "blocked" >>= assertSuccess + go (n + 1) desiredConvDomain -- Copied from `src/CargoHold/API/V3.hs` and inlined to avoid pulling in `types-common` randomToken :: (HasCallStack) => App String @@ -399,18 +414,25 @@ randomScimUser :: App Value randomScimUser = randomScimUserWith def randomScimUserWithEmail :: String -> String -> App Value -randomScimUserWithEmail extId email = +randomScimUserWithEmail extId email = randomScimUserWithEmailAndMeta extId email Nothing Nothing + +randomScimUserWithEmailAndMeta :: String -> String -> Maybe String -> Maybe Bool -> App Value +randomScimUserWithEmailAndMeta extId email ty pr = randomScimUserWith def { mkExternalId = pure extId, prependExternalIdToEmails = False, - mkOtherEmails = pure [email] + mkOtherEmails = pure [email], + mkEmailType = pure ty, + mkEmailPrimary = pure pr } data RandomScimUserParams = RandomScimUserParams { mkExternalId :: App String, prependExternalIdToEmails :: Bool, -- NB: this flag is also honored if externalId is not an email! - mkOtherEmails :: App [String] + mkOtherEmails :: App [String], + mkEmailType :: App (Maybe String), -- SCIM `type` attached to each email entry; emitted only when 'Just'. + mkEmailPrimary :: App (Maybe Bool) -- SCIM `primary` attached to each email entry; emitted only when 'Just'. } instance Default RandomScimUserParams where @@ -418,14 +440,22 @@ instance Default RandomScimUserParams where RandomScimUserParams { mkExternalId = randomEmail, prependExternalIdToEmails = True, - mkOtherEmails = pure [] + mkOtherEmails = pure [], + mkEmailType = pure Nothing, + mkEmailPrimary = pure Nothing } randomScimUserWith :: (HasCallStack) => RandomScimUserParams -> App Value randomScimUserWith params = do extId <- params.mkExternalId + ty <- params.mkEmailType + pr <- params.mkEmailPrimary emails <- do - let mk email = object ["value" .= email] + let mk email = + object $ + ["value" .= email] + <> ["type" .= t | Just t <- [ty]] + <> ["primary" .= p | Just p <- [pr]] hd = [extId | params.prependExternalIdToEmails] tl <- params.mkOtherEmails pure $ Array (fromList (mk <$> (hd <> tl))) @@ -677,6 +707,35 @@ nextSubject = do 1 -> liftIO $ SAML.mkUNameIDUnspecified . UUID.toText <$> nextRandom either (error . show) pure $ SAML.mkNameID unameId Nothing Nothing Nothing +-- | Generate a random email address and the corresponding email-based `SAML.NameID`. +randomEmailNameId :: (HasCallStack) => App (String, SAML.NameID) +randomEmailNameId = do + email <- randomEmail + let nameId = fromRight (error "could not create name id") $ SAML.emailNameID (Text.pack email) + pure (email, nameId) + +-- | Extract and decode base64 content from HTML error page
 tag.
+--
+-- This is meant to decode SAML errors embedded in HTML pages; e.g. from
+-- @/sso/finalize-login@.
+extractSAMLErrorPageContent :: (HasCallStack) => ByteString -> String
+extractSAMLErrorPageContent body =
+  let bdy = unpack body
+   in case bdy =~ ("
([A-Za-z0-9+/=]+)
" :: String) :: (String, String, String, [String]) of + (_, _, _, [b64Content]) -> cs $ Base64.decodeLenient (cs b64Content) + _ -> error "Could not extract base64 content from
 tag"
+
+-- | Helper to create IdP metadata with a fixed issuer suffix
+makeSampleIdPMetadataWithIssuer ::
+  (HasCallStack) =>
+  (SAML.SignPrivCreds, SAML.SignCreds, X509.SignedCertificate) -> String -> App SampleIdP
+makeSampleIdPMetadataWithIssuer (privcreds, creds, cert) suffix = do
+  let issuerUri = Text.pack $ "https://issuer.net/_" <> suffix
+      requriUri = Text.pack $ "https://requri.net/_req_" <> suffix
+      issuer = SAML.Issuer . fromRight' $ SAML.parseURI' issuerUri
+      requri = fromRight' $ SAML.parseURI' requriUri
+  pure $ SampleIdP (SAML.IdPMetadata issuer requri (cert NonEmpty.:| [])) privcreds creds cert
+
 -- helpers
 
 data ChallengeSetup = ChallengeSetup
diff --git a/integration/test/Test/AdminlessGroups.hs b/integration/test/Test/AdminlessGroups.hs
index d2bd97e5e21..139d5439357 100644
--- a/integration/test/Test/AdminlessGroups.hs
+++ b/integration/test/Test/AdminlessGroups.hs
@@ -20,7 +20,10 @@ module Test.AdminlessGroups where
 import API.Brig
 import API.Galley
 import API.GalleyInternal hiding (getConversation)
+import qualified API.GalleyInternal as GalleyI
+import Control.Concurrent (threadDelay)
 import MLS.Util
+import Notifications
 import SetupHelpers hiding (deleteUser)
 import Testlib.Prelude
 
@@ -29,31 +32,44 @@ testOnLastAdminLeaveReturnEligibleMembers = do
   -- bob is eligible
   (alice, tid, [bob]) <- createTeam OwnDomain 2
 
-  setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked"
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
   patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess
 
   -- local user is eligible
   localUser <- randomUser OwnDomain def
   connectTwoUsers alice localUser
 
+  -- ephemeral user is not eligible
+  tmpUser <- ephemeralUser OwnDomain
+
   -- a remote user is not eligible
   remoteUser <- randomUser OtherDomain def
   connectTwoUsers alice remoteUser
 
   -- app is not eligible
-  let newApp :: NewApp
-      newApp = def {name = "some-app", description = "non-eligible app member"}
+  let newApp = def {name = "some-app", description = "non-eligible app member"}
   app <- bindResponse (createApp alice tid newApp) $ \resp -> do
     resp.status `shouldMatchInt` 200
     resp.json %. "user"
 
-  clients@(alice1 : _) <- traverse (createMLSClient def) [alice, bob, localUser, remoteUser, app]
+  clients@(alice1 : tmpUser1 : _) <- traverse (createMLSClient def) [alice, tmpUser, bob, localUser, remoteUser, app]
   for_ clients (uploadNewKeyPackage def)
 
-  conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201
+  conv <- createTeamMLSConversation alice tid alice1 [bob, app, localUser, remoteUser]
   convId <- objConvId conv
-  createGroup def alice1 convId
-  void $ createAddCommit alice1 convId [bob, app, localUser, remoteUser] >>= sendAndConsumeCommitBundle
+
+  (key, code) <- bindResponse (postConversationCode alice conv Nothing Nothing) $ \resp -> do
+    res <- getJSON 201 resp
+    (,) <$> (res %. "data.key" & asString) <*> (res %. "data.code" & asString)
+  postJoinCodeConv tmpUser key code >>= assertSuccess
+  void $ createExternalCommit convId tmpUser1 Nothing >>= sendAndConsumeCommitBundle
+
+  GalleyI.getConversation conv `bindResponse` \resp -> do
+    resp.status `shouldMatchInt` 200
+    members <- resp.json %. "members.others" >>= asList
+    actual <- traverse (\m -> m %. "qualified_id") members
+    expected <- traverse (\m -> m %. "qualified_id") [alice, tmpUser, bob, localUser, remoteUser, app]
+    actual `shouldMatchSet` expected
 
   assertAttemptToLeaveFails conv alice [bob, localUser]
 
@@ -98,28 +114,448 @@ testOnLastAdminLeaveReturnEligibleMembers = do
 testOnLastAdminLeaveNoEligibleMembersExist :: (HasCallStack) => App ()
 testOnLastAdminLeaveNoEligibleMembersExist = do
   (alice, tid, _) <- createTeam OwnDomain 1
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" ["9s", "8s"]
 
-  setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked"
-  patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess
+  tmpUser <- ephemeralUser OwnDomain
 
   alice1 <- createMLSClient def alice
-  void $ uploadNewKeyPackage def alice1
+  tmpUser1 <- createMLSClient def tmpUser
+  traverse_ (uploadNewKeyPackage def) [alice1, tmpUser1]
 
-  conv <- postConversation alice defMLS {team = Just tid} >>= getJSON 201
+  conv <- createTeamMLSConversation alice tid alice1 []
+  let newApp = def {name = "adminless-reminder-app", description = "not eligible for promotion"}
+  (app, _) <- createAndAddAppMember alice tid alice1 conv newApp
   convId <- objConvId conv
-  createGroup def alice1 convId
-  void $ createAddCommit alice1 convId [] >>= sendAndConsumeCommitBundle
 
-  -- alice leaves the conversation, no error, group will be marked for deletion
+  (key, code) <- bindResponse (postConversationCode alice conv Nothing Nothing) $ \resp -> do
+    res <- getJSON 201 resp
+    (,) <$> (res %. "data.key" & asString) <*> (res %. "data.code" & asString)
+  postJoinCodeConv tmpUser key code >>= assertSuccess
+  void $ createExternalCommit convId tmpUser1 Nothing >>= sendAndConsumeCommitBundle
+
+  GalleyI.getConversation conv `bindResponse` \resp -> do
+    resp.status `shouldMatchInt` 200
+    members <- resp.json %. "members.others" >>= asList
+    actual <- traverse (\m -> m %. "qualified_id") members
+    expected <- traverse (\m -> m %. "qualified_id") [app, alice, tmpUser]
+    actual `shouldMatchSet` expected
+
+  withWebSockets [app, tmpUser] $ \[wsApp, wsTmpUser] -> do
+    -- alice leaves the conversation, no error, group will be marked for deletion
+    bindResponse (removeMember alice conv alice) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+    void $ awaitNMatches 2 isConvAdminlessReminderNotif wsApp
+    void $ awaitNMatches 2 isConvAdminlessReminderNotif wsTmpUser
+
+    -- The deletion event is sent after the conversation has been removed. The
+    -- suite's local timeout is only 2s, but this job is scheduled 10s ahead.
+    -- Use a longer timeout here and avoid racing the final HTTP assertion.
+    void $ awaitMatchFor 15 isConvDeleteNotif wsApp
+    bindResponse (GalleyI.getConversation conv) $ \resp -> do
+      resp.status `shouldMatchInt` 404
+
+testAdminlessSetupOnFeatureEnable :: (HasCallStack) => App ()
+testAdminlessSetupOnFeatureEnable = do
+  (alice, tid, _) <- createTeam OwnDomain 1
+
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
+  patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
+
+  alice1 <- createMLSClient def alice
+  void $ uploadNewKeyPackage def alice1
+  conv <- createTeamMLSConversation alice tid alice1 []
+  let newApp = def {name = "adminless-setup-app", description = "not eligible for promotion"}
+  (app, _) <- createAndAddAppMember alice tid alice1 conv newApp
+
+  -- The feature is disabled, so leaving the conversation must not schedule a
+  -- deletion job. Enabling it afterwards exercises the team reconciliation job.
+  bindResponse (removeMember alice conv alice) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+  bindResponse (GalleyI.getConversation conv) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+
+  withWebSockets [app] $ \[wsApp] -> do
+    configureAdminlessGroupsFeature OwnDomain tid "enabled" "5s" ["4s"]
+
+    -- Leave enough margin for the setup job to enqueue both jobs and for them
+    -- to be picked up when the integration suite is under load.
+    reminder <- awaitMatchFor 20 isConvSystemAdminlessReminderNotif wsApp
+    reminder %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv
+    void $ reminder %. "payload.0.data.deletion_scheduled_for" & asString
+    void $ awaitMatchFor 20 isConvDeleteNotif wsApp
+    bindResponse (GalleyI.getConversation conv) $ \resp -> do
+      resp.status `shouldMatchInt` 404
+
+testAdminlessReplaceMembers :: (HasCallStack) => App ()
+testAdminlessReplaceMembers = do
+  testVersion 16 $ \alice bob conv version -> do
+    bobId <- bob %. "qualified_id"
+    -- V16 retains the legacy behavior and autopromotes the remaining eligible member.
+    bindResponse (replaceMembers alice conv def {users = [bobId], version = Just version}) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+    bindResponse (getConversation bob conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+      members <- resp.json %. "members.others" & asList
+      shouldBeEmpty members
+
+  testVersion 18 $ \alice bob conv version -> do
+    bobId <- bob %. "qualified_id"
+    -- V18 rejects a replacement that would remove the last admin while leaving
+    -- only eligible non-admin members.
+    bindResponse (replaceMembers alice conv def {users = [bobId], version = Just version}) $ \resp -> do
+      resp.status `shouldMatchInt` 403
+      resp.json %. "label" `shouldMatch` "adminless-conversation"
+      eligibleMembers <- resp.json %. "eligible_members" & asList
+      eligibleMembers `shouldMatchSet` [bobId]
+
+    -- The rejected replacement does not mutate membership: Bob is still a
+    -- member and Alice is still the admin.
+    bindResponse (getConversation alice conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+      members <- resp.json %. "members.others" & asList
+      memberIds <- traverse (%. "qualified_id") members
+      memberIds `shouldMatchSet` [bobId]
+  where
+    testVersion version assertResult = do
+      (alice, tid, [bob]) <- createTeam OwnDomain 2
+      configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" []
+      conv <- postConversation alice (defProteus {team = Just tid, qualifiedUsers = [bob], newUsersRole = "wire_member"}) >>= getJSON 201
+      assertResult alice bob conv version
+
+testAdminlessReplaceMembersAddsAdmin :: (HasCallStack) => App ()
+testAdminlessReplaceMembersAddsAdmin = do
+  (alice, tid, [bob, charlie]) <- createTeam OwnDomain 3
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" []
+  conv <- postConversation alice (defProteus {team = Just tid, qualifiedUsers = [bob], newUsersRole = "wire_member"}) >>= getJSON 201
+  bobId <- bob %. "qualified_id"
+  charlieId <- charlie %. "qualified_id"
+
+  -- V18 accepts replacing the existing admin when the same request adds a new
+  -- admin, because the resulting conversation is not adminless.
+  bindResponse (replaceMembers alice conv def {users = [bobId, charlieId], role = Just "wire_admin", version = Just 18}) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+
+  bindResponse (getConversation charlie conv) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+    members <- resp.json %. "members.others" & asList
+    memberIds <- traverse (%. "qualified_id") members
+    memberIds `shouldMatchSet` [bobId]
+
+testAdminlessReplaceMembersAddsEligibleMember :: (HasCallStack) => App ()
+testAdminlessReplaceMembersAddsEligibleMember = do
+  (alice, tid, [bob]) <- createTeam OwnDomain 2
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" []
+  conv <- postConversation alice (defProteus {team = Just tid, qualifiedUsers = [], newUsersRole = "wire_member"}) >>= getJSON 201
+  bobId <- bob %. "qualified_id"
+
+  -- V18 rejects a replacement that removes the only admin even when the
+  -- eligible member is added by the same request.
+  bindResponse
+    (replaceMembers alice conv def {users = [bobId], role = Just "wire_member", version = Just 18})
+    $ \resp -> do
+      resp.status `shouldMatchInt` 403
+      resp.json %. "label" `shouldMatch` "adminless-conversation"
+      eligibleMembers <- resp.json %. "eligible_members" & asList
+      eligibleMembers `shouldMatchSet` [bobId]
+
+  bindResponse (getConversation alice conv) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+
+testAdminlessSetupSystemMemberUpdate :: (HasCallStack) => App ()
+testAdminlessSetupSystemMemberUpdate = do
+  (alice, tid, [bob]) <- createTeam OwnDomain 2
+
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
+  patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
+
+  [alice1, bob1] <- traverse (createMLSClient def) [alice, bob]
+  traverse_ (uploadNewKeyPackage def) [alice1, bob1]
+
+  conv <- createTeamMLSConversation alice tid alice1 [bob]
+
+  -- Create an adminless conversation while the feature is disabled. The setup
+  -- job will later autopromote bob without an originating user ID.
+  bindResponse (removeMember alice conv alice) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+
+  withWebSockets [bob] $ \[wsBob] -> do
+    patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess
+
+    notif <- awaitMatchFor 20 isConvSystemMemberUpdateNotif wsBob
+    notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv
+    notif %. "payload.0.data.qualified_target" `shouldMatch` objQidObject bob
+    notif %. "payload.0.data.conversation_role" `shouldMatch` "wire_admin"
+
+    bindResponse (getConversation bob conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+
+testAdminlessSetupMemberUpdateAfterAdminLeaves :: (HasCallStack) => App ()
+testAdminlessSetupMemberUpdateAfterAdminLeaves = do
+  (alice, tid, [bob]) <- createTeam OwnDomain 2
+
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
+  patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
+
+  conv <-
+    postConversation
+      alice
+      (defProteus {team = Just tid, qualifiedUsers = [bob], newUsersRole = "wire_member"})
+      >>= getJSON 201
+
+  -- Alice leaves while the feature is disabled. Enabling the feature through
+  -- the public endpoint then reconciles the now-adminless conversation
   bindResponse (removeMember alice conv alice) $ \resp -> do
     resp.status `shouldMatchInt` 200
 
+  withWebSockets [bob] $ \[wsBob] -> do
+    setTeamFeatureConfigVersioned (ExplicitVersion 18) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "10s" []) >>= assertSuccess
+
+    notif <- awaitMatchFor 20 isMemberUpdateNotif wsBob
+    notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv
+    notif %. "payload.0.data.qualified_target" `shouldMatch` objQidObject bob
+    notif %. "payload.0.data.conversation_role" `shouldMatch` "wire_admin"
+
+    bindResponse (getConversation bob conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+
+testAdminlessSetupDeletesWithOriginAndRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupDeletesWithOriginAndRemoteMembers = do
+  (alice, tid, _) <- createTeam OwnDomain 1
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
+  patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
+
+  conv <-
+    postConversation
+      alice
+      (defProteus {team = Just tid, qualifiedUsers = [remoteUser], newUsersRole = "wire_member"})
+      >>= getJSON 201
+  convQid <- objQidObject conv
+
+  removeMember alice conv alice >>= assertSuccess
+
+  eventually $ bindResponse (listConversationIds remoteUser def) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    conversationIds <- resp.json %. "qualified_conversations" & asList
+    conversationIds `shouldContain` [convQid]
+
+  withWebSockets [remoteUser] $ \[wsRemoteUser] -> do
+    setTeamFeatureConfigVersioned (ExplicitVersion 18) alice tid "preventAdminlessGroups" (mkAdminlessFeature "enabled" "1s" []) >>= assertSuccess
+
+    deleteNotif <- awaitMatchFor 20 isConvDeleteNotif wsRemoteUser
+    deleteNotif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice
+
+    eventually $ bindResponse (listConversationIds remoteUser def) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      conversationIds <- resp.json %. "qualified_conversations" & asList
+      conversationIds `shouldNotContain` [convQid]
+
+testAdminlessSetupSkipsDeletionForRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupSkipsDeletionForRemoteMembers = do
+  -- Senderless deletion is skipped when remote members are present because
+  -- remote backends do not support the system delete event yet.
+  (alice, tid, _) <- createTeam OwnDomain 1
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  configureAdminlessGroupsFeature OwnDomain tid "disabled" "1s" []
+
+  alice1 <- createMLSClient def alice
+  remoteUser1 <- createMLSClient def remoteUser
+  traverse_ (uploadNewKeyPackage def) [alice1, remoteUser1]
+
+  conv <- createTeamMLSConversation alice tid alice1 [remoteUser]
+
+  -- Create an adminless conversation while the feature is disabled. Enabling
+  -- the feature later exercises the system-triggered setup path.
+  removeMember alice conv alice >>= assertSuccess
+
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "1s" ["1s"]
+
+  -- The setup job must not schedule deletion or reminders for this
+  -- conversation because it contains a remote member.
+  liftIO $ threadDelay 2_000_000
+  bindResponse (GalleyI.getConversation conv) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+
+testAdminlessSetupSkipsReminderForRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupSkipsReminderForRemoteMembers = do
+  -- A remote member prevents senderless deletion. The remaining local app is
+  -- not eligible for promotion, but would receive a system reminder if one
+  -- were emitted.
+  (alice, tid, _) <- createTeam OwnDomain 1
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  configureAdminlessGroupsFeature OwnDomain tid "disabled" "5s" ["4s"]
+
+  alice1 <- createMLSClient def alice
+  remoteUser1 <- createMLSClient def remoteUser
+  traverse_ (uploadNewKeyPackage def) [alice1, remoteUser1]
+
+  conv <- createTeamMLSConversation alice tid alice1 [remoteUser]
+  let newApp = def {name = "adminless-federated-reminder-app", description = "not eligible for promotion"}
+  (app, _) <- createAndAddAppMember alice tid alice1 conv newApp
+
+  -- Create an adminless conversation while the feature is disabled. Enabling
+  -- it through the internal path runs senderless setup cleanup.
+  removeMember alice conv alice >>= assertSuccess
+
+  withWebSockets [app] $ \[wsApp] -> do
+    configureAdminlessGroupsFeature OwnDomain tid "enabled" "2s" ["1s"]
+
+    reminderResult <- awaitNMatchesResultFor 5 1 isConvSystemAdminlessReminderNotif wsApp
+    reminderResult.success `shouldMatch` False
+
+    bindResponse (GalleyI.getConversation conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+testAdminlessSetupAutopromotesWithRemoteMembers :: (HasCallStack) => App ()
+testAdminlessSetupAutopromotesWithRemoteMembers = do
+  -- Autopromotion is safe with remote members because the owning backend is
+  -- authoritative for roles, even though remote clients do not receive the
+  -- senderless system member-update event yet.
+  (alice, tid, [bob]) <- createTeam OwnDomain 2
+  remoteUser <- randomUser OtherDomain def
+  connectTwoUsers alice remoteUser
+
+  configureAdminlessGroupsFeature OwnDomain tid "disabled" "1s" []
+
+  alice1 <- createMLSClient def alice
+  bob1 <- createMLSClient def bob
+  remoteUser1 <- createMLSClient def remoteUser
+  traverse_ (uploadNewKeyPackage def) [alice1, bob1, remoteUser1]
+
+  conv <- createTeamMLSConversation alice tid alice1 [bob, remoteUser]
+
+  -- Create an adminless conversation while the feature is disabled. Enabling
+  -- the feature later promotes Bob through a system action.
+  removeMember alice conv alice >>= assertSuccess
+
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "1s" []
+
+  liftIO $ threadDelay 2_000_000
+  bindResponse (getConversation bob conv) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
+
+testAdminlessJobsCancelledOnFeatureDisable :: (HasCallStack) => App ()
+testAdminlessJobsCancelledOnFeatureDisable = do
+  (alice, tid, _) <- createTeam OwnDomain 1
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "5s" []
+
+  alice1 <- createMLSClient def alice
+  void $ uploadNewKeyPackage def alice1
+  conv <- createTeamMLSConversation alice tid alice1 []
+  let newApp = def {name = "adminless-cancel-app", description = "not eligible for promotion"}
+  (app, _) <- createAndAddAppMember alice tid alice1 conv newApp
+
+  withWebSockets [app] $ \[wsApp] -> do
+    -- Leaving schedules deletion while the feature is enabled.
+    bindResponse (removeMember alice conv alice) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+    -- Disabling the feature must cancel the pending deletion before its deadline.
+    patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
+
+    -- Wait beyond the original deadline and worker polling window. The
+    -- conversation must remain because the pending job was canceled. Feature
+    -- update events are ignored by the matcher.
+    result <- awaitNMatchesResultFor 15 1 isConvDeleteNotif wsApp
+    result.success `shouldMatch` False
+    bindResponse (GalleyI.getConversation conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+testAdminlessJobsRecreatedOnFeatureConfigChange :: (HasCallStack) => App ()
+testAdminlessJobsRecreatedOnFeatureConfigChange = do
+  (alice, tid, _) <- createTeam OwnDomain 1
+
+  configureAdminlessGroupsFeature OwnDomain tid "enabled" "10s" []
+
+  alice1 <- createMLSClient def alice
+  void $ uploadNewKeyPackage def alice1
+  conv <- createTeamMLSConversation alice tid alice1 []
+  let newApp = def {name = "adminless-reschedule-app", description = "not eligible for promotion"}
+  (app, _) <- createAndAddAppMember alice tid alice1 conv newApp
+
+  withWebSockets [app] $ \[wsApp] -> do
+    -- Leaving schedules a deletion using the original timeout.
+    bindResponse (removeMember alice conv alice) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+    -- Changing the configuration must cancel the old job and recreate it
+    -- using the new timeout.
+    configureAdminlessGroupsFeature OwnDomain tid "enabled" "20s" []
+
+    -- If the old job was not canceled, it would delete the conversation after
+    -- 10s. Wait past that deadline before checking that it still exists.
+    oldJobResult <- awaitNMatchesResultFor 15 1 isConvDeleteNotif wsApp
+    oldJobResult.success `shouldMatch` False
+    bindResponse (GalleyI.getConversation conv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+    -- The replacement job uses the new 20s timeout.
+    void $ awaitMatchFor 30 isConvDeleteNotif wsApp
+    bindResponse (GalleyI.getConversation conv) $ \resp -> do
+      resp.status `shouldMatchInt` 404
+
+testAdminlessJobCancellationIsTeamScoped :: (HasCallStack) => App ()
+testAdminlessJobCancellationIsTeamScoped = do
+  (alice, canceledTid, _) <- createTeam OwnDomain 1
+  (bob, activeTid, _) <- createTeam OwnDomain 1
+
+  let enabledFeature = mkAdminlessFeature "enabled" "10s" []
+
+  for_ [canceledTid, activeTid] $ \tid -> do
+    setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
+    patchTeamFeature OwnDomain tid "preventAdminlessGroups" enabledFeature >>= assertSuccess
+
+  let newApp name = def {name = name, description = "not eligible for promotion"}
+  alice1 <- createMLSClient def alice
+  bob1 <- createMLSClient def bob
+  traverse_ (uploadNewKeyPackage def) [alice1, bob1]
+
+  canceledConv <- createTeamMLSConversation alice canceledTid alice1 []
+  (canceledApp, _) <- createAndAddAppMember alice canceledTid alice1 canceledConv (newApp "adminless-cancel-team-app")
+
+  activeConv <- createTeamMLSConversation bob activeTid bob1 []
+  (activeApp, _) <- createAndAddAppMember bob activeTid bob1 activeConv (newApp "adminless-active-team-app")
+
+  withWebSockets [canceledApp, activeApp] $ \[wsCanceled, wsActive] -> do
+    -- Schedule one deletion job for each team before disabling only one team.
+    bindResponse (removeMember alice canceledConv alice) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+    bindResponse (removeMember bob activeConv bob) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+
+    patchTeamFeature OwnDomain canceledTid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
+
+    -- The cancellation query must not affect the other team's job.
+    canceledResult <- awaitNMatchesResultFor 20 1 isConvDeleteNotif wsCanceled
+    canceledResult.success `shouldMatch` False
+    void $ awaitMatchFor 30 isConvDeleteNotif wsActive
+
+    bindResponse (GalleyI.getConversation canceledConv) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+    bindResponse (GalleyI.getConversation activeConv) $ \resp -> do
+      resp.status `shouldMatchInt` 404
+
 testOnLastAdminLeaveFeatureDisabled :: (HasCallStack) => App ()
 testOnLastAdminLeaveFeatureDisabled = do
   -- bob is eligible
   (alice, tid, [bob]) <- createTeam OwnDomain 2
 
-  setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked"
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
   patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "disabled"]) >>= assertSuccess
 
   clients@(alice1 : _) <- traverse (createMLSClient def) [alice, bob]
@@ -138,7 +574,7 @@ testOnLastAdminTeamMemberDeletionAutopromotes :: (HasCallStack) => App ()
 testOnLastAdminTeamMemberDeletionAutopromotes = do
   (alice, tid, [charlie]) <- createTeam OwnDomain 2
 
-  setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked"
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
   patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess
 
   [alice1, charlie1] <- traverse (createMLSClient def) [alice, charlie]
@@ -171,7 +607,7 @@ testOnLastAdminSelfDeletionAutopromotes :: (HasCallStack) => App ()
 testOnLastAdminSelfDeletionAutopromotes = do
   (alice, tid, [charlie]) <- createTeam OwnDomain 2
 
-  setTeamFeatureLockStatus alice tid "preventAdminlessGroups" "unlocked"
+  setTeamFeatureLockStatus OwnDomain tid "preventAdminlessGroups" "unlocked"
   patchTeamFeature OwnDomain tid "preventAdminlessGroups" (object ["status" .= "enabled"]) >>= assertSuccess
 
   [alice1, charlie1] <- traverse (createMLSClient def) [alice, charlie]
@@ -198,3 +634,45 @@ testOnLastAdminSelfDeletionAutopromotes = do
       resp.json %. "members.self.conversation_role" `shouldMatch` "wire_admin"
       members <- resp.json %. "members.others" & asList
       shouldBeEmpty members
+
+-----------------------------------------------------------------------------------------------------------------------------
+-- UTILS
+
+createTeamMLSConversation :: (HasCallStack, MakesValue owner) => owner -> String -> ClientIdentity -> [Value] -> App Value
+createTeamMLSConversation owner tid ownerClient members = do
+  conv <- postConversation owner (allowAll defMLS) {team = Just tid} >>= getJSON 201
+  convId <- objConvId conv
+  createGroup def ownerClient convId
+  unless (null members)
+    $ void
+    $ createAddCommit ownerClient convId members
+    >>= sendAndConsumeCommitBundle
+  pure conv
+
+createAndAddAppMember :: (HasCallStack, MakesValue creator, MakesValue conv) => creator -> String -> ClientIdentity -> conv -> NewApp -> App (Value, ClientIdentity)
+createAndAddAppMember creator tid ownerClient conv newApp = do
+  app <- bindResponse (createApp creator tid newApp) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "user"
+  appClient <- createMLSClient def app
+  void $ uploadNewKeyPackage def appClient
+  convId <- objConvId conv
+  void $ createAddCommit ownerClient convId [app] >>= sendAndConsumeCommitBundle
+  pure (app, appClient)
+
+configureAdminlessGroupsFeature :: (MakesValue domain) => domain -> String -> String -> String -> [String] -> App ()
+configureAdminlessGroupsFeature domain tid status deletionTimeout reminderTimeouts = do
+  setTeamFeatureLockStatus domain tid "preventAdminlessGroups" "unlocked"
+  patchTeamFeature domain tid "preventAdminlessGroups" (mkAdminlessFeature status deletionTimeout reminderTimeouts) >>= assertSuccess
+
+mkAdminlessFeature :: String -> String -> [String] -> Value
+mkAdminlessFeature status deletionTimeout reminderTimeouts =
+  object
+    [ "status" .= status,
+      "config"
+        .= object
+          [ "deletionTimeoutDuration" .= deletionTimeout,
+            "reminderTimeoutDurations" .= reminderTimeouts,
+            "promotionStrategy" .= "random"
+          ]
+    ]
diff --git a/integration/test/Test/Bot.hs b/integration/test/Test/Bot.hs
index f96bc7733ae..6fb579abae1 100644
--- a/integration/test/Test/Bot.hs
+++ b/integration/test/Test/Bot.hs
@@ -38,6 +38,7 @@ import Servant.Server
 import SetupHelpers
 import Testlib.Certs
 import Testlib.MockIntegrationService
+import Testlib.Prekeys
 import Testlib.Prelude
 import UnliftIO
 
diff --git a/integration/test/Test/Cells.hs b/integration/test/Test/Cells.hs
index 1f82013a9ad..06c46dd243e 100644
--- a/integration/test/Test/Cells.hs
+++ b/integration/test/Test/Cells.hs
@@ -42,7 +42,7 @@ testCellsEvent :: (HasCallStack) => App ()
 testCellsEvent = do
   (alice, tid, [bob, chaz, dean, eve]) <- createTeam OwnDomain 5
   conv <- postConversation alice defProteus {team = Just tid} >>= getJSON 201
-  q <- watchCellsEventsForTeam tid (convEvents conv)
+  cellsQueue <- watchCellsEventsForTeam tid (convEvents conv)
 
   bobId <- bob %. "qualified_id"
   chazId <- chaz %. "qualified_id"
@@ -55,7 +55,7 @@ testCellsEvent = do
   addMembers alice conv def {role = Just "wire_member", users = [chazId]} >>= assertSuccess
 
   do
-    event <- getMessage q %. "payload.0"
+    event <- getMessage cellsQueue %. "payload.0"
     event %. "type" `shouldMatch` "conversation.member-join"
     event %. "conversation" `shouldMatch` (conv %. "qualified_id" & objId)
     event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
@@ -66,7 +66,7 @@ testCellsEvent = do
   addMembers alice conv def {role = Just "wire_member", users = [deanId]} >>= assertSuccess
 
   do
-    event <- getMessage q %. "payload.0"
+    event <- getMessage cellsQueue %. "payload.0"
     event %. "type" `shouldMatch` "conversation.member-join"
     event %. "conversation" `shouldMatch` (conv %. "qualified_id" & objId)
     event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
@@ -76,33 +76,33 @@ testCellsEvent = do
   I.setCellsState alice conv "disabled" >>= assertSuccess
   addMembers alice conv def {role = Just "wire_member", users = [eveId]} >>= assertSuccess
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsCreationEvent :: (HasCallStack) => App ()
 testCellsCreationEvent = do
   (alice, tid, _) <- createTeam OwnDomain 1
-  q0 <- watchCellsEventsForTeam tid def
+  baseQueue <- watchCellsEventsForTeam tid def
   conv <- postConversation alice defProteus {team = Just tid, cells = True} >>= getJSON 201
 
-  let q = q0 {filter = isNotifConv conv} :: QueueConsumer
+  let cellsQueue = baseQueue {filter = isNotifConv conv} :: QueueConsumer
 
-  event <- getMessage q %. "payload.0"
+  event <- getMessage cellsQueue %. "payload.0"
   event %. "type" `shouldMatch` "conversation.create"
   event %. "qualified_conversation.id" `shouldMatch` (conv %. "qualified_id.id")
   event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsDeletionEvent :: (HasCallStack) => App ()
 testCellsDeletionEvent = do
   (alice, tid, _) <- createTeam OwnDomain 1
-  q0 <- watchCellsEventsForTeam tid def
+  baseQueue <- watchCellsEventsForTeam tid def
   conv <- postConversation alice defProteus {team = Just tid, cells = True} >>= getJSON 201
   void $ deleteTeamConversation tid conv alice >>= assertSuccess
 
-  let q = q0 {filter = isConvDeleteNotif} :: QueueConsumer
+  let cellsQueue = baseQueue {filter = isNotifTeamConvDelete conv} :: QueueConsumer
 
-  event <- getMessage q %. "payload.0"
+  event <- getMessage cellsQueue %. "payload.0"
   event %. "type" `shouldMatch` "conversation.delete"
   event %. "conversation" `shouldMatch` (conv %. "qualified_id.id")
   event %. "qualified_conversation" `shouldMatch` (conv %. "qualified_id")
@@ -110,22 +110,22 @@ testCellsDeletionEvent = do
   event %. "from" `shouldMatch` (alice %. "qualified_id.id")
   event %. "team" `shouldMatch` tid
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsCreationEventIsSentOnlyOnce :: (HasCallStack) => App ()
 testCellsCreationEventIsSentOnlyOnce = do
   (alice, tid, members) <- createTeam OwnDomain 2
-  q0 <- watchCellsEventsForTeam tid def
+  baseQueue <- watchCellsEventsForTeam tid def
   conv <- postConversation alice defProteus {team = Just tid, cells = True, qualifiedUsers = members} >>= getJSON 201
 
-  let q = q0 {filter = isNotifConv conv} :: QueueConsumer
+  let cellsQueue = baseQueue {filter = isNotifConv conv} :: QueueConsumer
 
-  event <- getMessage q %. "payload.0"
+  event <- getMessage cellsQueue %. "payload.0"
   event %. "type" `shouldMatch` "conversation.create"
   event %. "qualified_conversation.id" `shouldMatch` (conv %. "qualified_id.id")
   event %. "qualified_from" `shouldMatch` (alice %. "qualified_id")
 
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 testCellsFeatureCheck :: (HasCallStack) => App ()
 testCellsFeatureCheck = do
@@ -139,15 +139,15 @@ testCellsFeatureCheck = do
 testCellsEventOnFeatureToggle :: (HasCallStack) => App ()
 testCellsEventOnFeatureToggle = do
   (_, tid, _) <- createTeam OwnDomain 1
-  q <- watchCellsEventsForTeam tid def
+  cellsQueue <- watchCellsEventsForTeam tid def
   I.patchTeamFeature OwnDomain tid "cells" (object ["status" .= "disabled"]) >>= assertSuccess
-  getMessage q >>= \event -> do
+  getMessage cellsQueue >>= \event -> do
     event %. "payload.0.type" `shouldMatch` "feature-config.update"
     event %. "payload.0.name" `shouldMatch` "cells"
     event %. "payload.0.team" `shouldMatch` (asString tid)
     event %. "payload.0.data.status" `shouldMatch` "disabled"
   I.patchTeamFeature OwnDomain tid "cells" (object ["status" .= "enabled"]) >>= assertSuccess
-  getMessage q >>= \event -> do
+  getMessage cellsQueue >>= \event -> do
     event %. "payload.0.type" `shouldMatch` "feature-config.update"
     event %. "payload.0.name" `shouldMatch` "cells"
     event %. "payload.0.team" `shouldMatch` (asString tid)
@@ -166,9 +166,9 @@ testCellsIgnoredEvents = do
   (alice, tid, _) <- createTeam OwnDomain 1
   conv <- postConversation alice defProteus {team = Just tid} >>= getJSON 201
   I.setCellsState alice conv "ready" >>= assertSuccess
-  q <- watchCellsEventsForTeam tid (convEvents conv)
+  cellsQueue <- watchCellsEventsForTeam tid (convEvents conv)
   void $ updateMessageTimer alice conv 1000 >>= getBody 200
-  assertNoMessage q
+  assertNoMessage cellsQueue
 
 --------------------------------------------------------------------------------
 -- Utilities
@@ -211,26 +211,26 @@ connectToCellsQueue sm messages = do
       (cancelConsumer chan)
 
 getNextMessage :: QueueConsumer -> App Value
-getNextMessage q = do
-  m <- liftIO $ atomically $ readTChan q.chan
+getNextMessage cellsQueue = do
+  m <- liftIO $ atomically $ readTChan cellsQueue.chan
   v <- either assertFailure pure $ A.eitherDecode m.msgBody
-  ok <- q.filter v
+  ok <- cellsQueue.filter v
   if ok
     then pure v
-    else getNextMessage q
+    else getNextMessage cellsQueue
 
 getMessageMaybe :: QueueConsumer -> App (Maybe Value)
-getMessageMaybe q = do
+getMessageMaybe cellsQueue = do
   timeOutSeconds <- asks (.timeOutSeconds)
-  next <- appToIO (getNextMessage q)
+  next <- appToIO (getNextMessage cellsQueue)
   liftIO $ timeout (timeOutSeconds * 1000000) next
 
 getMessage :: QueueConsumer -> App Value
-getMessage q = getMessageMaybe q >>= assertJust "Cells queue timeout"
+getMessage cellsQueue = getMessageMaybe cellsQueue >>= assertJust "Cells queue timeout"
 
 assertNoMessage :: QueueConsumer -> App ()
-assertNoMessage f =
-  getMessageMaybe f >>= \case
+assertNoMessage cellsQueue =
+  getMessageMaybe cellsQueue >>= \case
     Nothing -> pure ()
     Just m -> do
       j <- prettyJSON m
@@ -307,8 +307,8 @@ watchCellsEvents opts = do
 
 watchCellsEventsForTeam :: String -> WatchCellsEvents -> App QueueConsumer
 watchCellsEventsForTeam tid opts = do
-  q <- watchCellsEvents opts
+  cellsQueue <- watchCellsEvents opts
   let isEventForTeam v = fieldEquals @Value v "payload.0.team" tid
   -- the cells event queue is shared by tests
   -- let's hope this filter reduces the risk of tests interfering with each other
-  pure $ q {filter = isEventForTeam}
+  pure $ cellsQueue {filter = isEventForTeam}
diff --git a/integration/test/Test/Conversation.hs b/integration/test/Test/Conversation.hs
index 0b7dc6a7d28..681df7f59fa 100644
--- a/integration/test/Test/Conversation.hs
+++ b/integration/test/Test/Conversation.hs
@@ -526,6 +526,10 @@ testAddUnreachableUserFromFederatingBackend domain = do
       otherDomain <- make domain & asString
       [alice, bob, charlie, chad] <-
         createAndConnectUsers [ownDomain, otherDomain, cDom.berDomain, cDom.berDomain]
+      -- Wait for ownDomain <-> {fed2, dynamicC} to be fully connected before
+      -- creating the conversation, so a transient fed2 reachability blip under
+      -- dynamic-backend churn does not turn the create into a 533 (WPB-3797).
+      assertFullyConnected alice [otherDomain, cDom.berDomain]
 
       conv <- withWebSockets [bob, charlie] $ \wss -> do
         conv <-
@@ -1302,23 +1306,35 @@ testMeetingGroupConvTypeHiddenInLegacy = do
     postConversation owner (defProteus {team = Just tid, groupConvType = Just "meeting"})
       >>= getJSON 201
   convQid <- conv %. "qualified_id"
+  convIdStr <- convQid %. "id" & asString
 
-  -- getConversation: V15 (legacy) omits group_conv_type for meetings,
-  -- V16+ exposes it.
+  -- getConversation: V15 (legacy) hides meeting conversations entirely (404);
+  -- V9 (legacy, OwnConversation-shaped response) hides them too; V16+ exposes them.
   bindResponse (getConversationVersioned (ExplicitVersion 15) owner conv) $ \resp -> do
-    resp.status `shouldMatchInt` 200
-    resp.json %. "group_conv_type" `shouldMatch` Null
+    resp.status `shouldMatchInt` 404
+    resp.json %. "label" `shouldMatch` ("no-conversation" :: String)
+
+  bindResponse (getConversationVersioned (ExplicitVersion 9) owner conv) $ \resp -> do
+    resp.status `shouldMatchInt` 404
+    resp.json %. "label" `shouldMatch` ("no-conversation" :: String)
 
   bindResponse (getConversation owner conv) $ \resp -> do
     resp.status `shouldMatchInt` 200
     resp.json %. "group_conv_type" `shouldMatch` ("meeting" :: String)
 
+  -- getConversations (legacy V1/V2 batch GET by ids): meeting conversations are
+  -- filtered out of the returned list, consistent with the single-GET hide.
+  bindResponse (getConversationsVersioned (ExplicitVersion 2) owner convIdStr) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    found <- resp.json %. "conversations" & asList
+    shouldBeEmpty found
+
   -- listConversations: V15 (legacy) excludes meeting conversations from found,
   -- V16+ includes them.
   bindResponse (listConversationsVersioned (ExplicitVersion 15) owner [convQid]) $ \resp -> do
     resp.status `shouldMatchInt` 200
     found <- resp.json %. "found" & asList
-    length (found :: [Value]) `shouldMatchInt` 0
+    shouldBeEmpty found
 
   bindResponse (listConversations owner [convQid]) $ \resp -> do
     resp.status `shouldMatchInt` 200
diff --git a/integration/test/Test/FeatureFlags.hs b/integration/test/Test/FeatureFlags.hs
index 49410110203..d93c15df60e 100644
--- a/integration/test/Test/FeatureFlags.hs
+++ b/integration/test/Test/FeatureFlags.hs
@@ -91,5 +91,12 @@ testNonMemberAccess :: (HasCallStack) => Feature -> App ()
 testNonMemberAccess (Feature featureName) = do
   (_, tid, _) <- createTeam OwnDomain 0
   nonMember <- randomUser OwnDomain def
-  Public.getTeamFeature nonMember tid featureName
-    >>= assertForbidden
+  -- meetingsPremium's public per-feature GET is version-gated at v17
+  -- (WPB-26771): it 404s there. Hit it at v16 so the non-member-access
+  -- authz check (403 no-team-member) is still exercised.
+  let getFeature = Public.getTeamFeature nonMember tid featureName
+  resp <-
+    if featureName `elem` ["meetingsPremium", "backgroundEffects"]
+      then withAPIVersion 16 getFeature
+      else getFeature
+  assertForbidden resp
diff --git a/integration/test/Test/FeatureFlags/Apps.hs b/integration/test/Test/FeatureFlags/Apps.hs
index b96543d142d..7c7afafea71 100644
--- a/integration/test/Test/FeatureFlags/Apps.hs
+++ b/integration/test/Test/FeatureFlags/Apps.hs
@@ -43,8 +43,9 @@ testAppsInternal = do
 testPatchApps :: (HasCallStack) => App ()
 testPatchApps = checkPatch OwnDomain "apps" disabled
 
--- | Disabling the apps feature for a team suspends all app users in that team.
--- Re-enabling it restores them to active.  Regular team members are unaffected.
+-- | Disabling the apps feature for a team suspends all app users in
+-- that team.  Re-enabling it does NOT restore them to active, since
+-- they may have been suspended for other reasons earlier.
 testAppsSuspendOnDisable :: (HasCallStack) => App ()
 testAppsSuspendOnDisable = do
   (owner, tid, [regularMember]) <- createTeam OwnDomain 2
@@ -84,9 +85,9 @@ testAppsSuspendOnDisable = do
     resp.status `shouldMatchInt` 200
     resp.json %. "status" `shouldMatch` "active"
 
-  -- Re-enable the apps feature: app users should be active again
+  -- Re-enable the apps feature: app users must NOT be re-activated
   setFeature InternalAPI owner tid "apps" enabled >>= assertSuccess
 
   BrigI.getAccountStatus app `bindResponse` \resp -> do
     resp.status `shouldMatchInt` 200
-    resp.json %. "status" `shouldMatch` "active"
+    resp.json %. "status" `shouldMatch` "suspended"
diff --git a/integration/test/Test/FeatureFlags/BackgroundEffects.hs b/integration/test/Test/FeatureFlags/BackgroundEffects.hs
index c2a568a0b84..5db0a5a6e2c 100644
--- a/integration/test/Test/FeatureFlags/BackgroundEffects.hs
+++ b/integration/test/Test/FeatureFlags/BackgroundEffects.hs
@@ -17,14 +17,49 @@
 
 module Test.FeatureFlags.BackgroundEffects where
 
+import SetupHelpers (createTeam)
 import Test.FeatureFlags.Util
 import Testlib.Prelude
 
 testPatchBackgroundEffects :: (HasCallStack) => App ()
-testPatchBackgroundEffects = checkPatch OwnDomain "backgroundEffects" enabled
+testPatchBackgroundEffects = withAPIVersion 16 $ checkPatch OwnDomain "backgroundEffects" enabled
 
 testBackgroundEffects :: (HasCallStack) => APIAccess -> App ()
 testBackgroundEffects access =
-  mkFeatureTests "backgroundEffects"
+  withAPIVersion 16
+    $ mkFeatureTests "backgroundEffects"
     & addUpdate enabled
     & runFeatureTests OwnDomain access
+
+-- | WPB-27912: the public backgroundEffects endpoints are gated at v17 (404)
+-- while remaining available through v16. Only the v16 GET is asserted here:
+-- v16 PUT success is covered by 'testBackgroundEffects' (whose runFeatureTests
+-- unlocks the feature first), and a public PUT in this test would 409
+-- feature-locked against the default enabled+locked state.
+testBackgroundEffectsRemovedAtV17 :: (HasCallStack) => App ()
+testBackgroundEffectsRemovedAtV17 = do
+  (owner, tid, []) <- createTeam OwnDomain 0
+  let p = joinHttpPath ["teams", tid, "features", "backgroundEffects"]
+      body = object ["status" .= "enabled", "lockStatus" .= "locked"]
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) p >>= submit "GET") $ assertStatus 404
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) p <&> addJSON body >>= submit "PUT") $ assertStatus 404
+  bindResponse (baseRequest owner Galley (ExplicitVersion 16) p >>= submit "GET") $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "status" `shouldMatch` "enabled"
+    resp.json %. "lockStatus" `shouldMatch` "locked"
+
+-- | Test version agnostic feature endpoints for `backgroundEffects`
+--
+-- Across versions, `backgroundEffects` is always set to `enabled` and `locked` to provide backwards compatibility.
+-- From `V17` on, the feature itself  has been removed.
+testBackgroundEffectsListedAtV17 :: (HasCallStack) => App ()
+testBackgroundEffectsListedAtV17 = do
+  (owner, tid, []) <- createTeam OwnDomain 0
+  let assertBackgroundEffects resp = do
+        resp.status `shouldMatchInt` 200
+        be <- resp.json %. "backgroundEffects"
+        be %. "status" `shouldMatch` "enabled"
+        be %. "lockStatus" `shouldMatch` "locked"
+      teamFeatures = joinHttpPath ["teams", tid, "features"]
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) "/feature-configs" >>= submit "GET") assertBackgroundEffects
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) teamFeatures >>= submit "GET") assertBackgroundEffects
diff --git a/integration/test/Test/FeatureFlags/MeetingPremium.hs b/integration/test/Test/FeatureFlags/MeetingPremium.hs
index ecbd2098684..df23d58b9d0 100644
--- a/integration/test/Test/FeatureFlags/MeetingPremium.hs
+++ b/integration/test/Test/FeatureFlags/MeetingPremium.hs
@@ -17,14 +17,53 @@
 
 module Test.FeatureFlags.MeetingPremium where
 
+import SetupHelpers (createTeam)
 import Test.FeatureFlags.Util
 import Testlib.Prelude
 
 testPatchMeetingPremium :: (HasCallStack) => App ()
-testPatchMeetingPremium = checkPatch OwnDomain "meetingsPremium" disabledLocked
+testPatchMeetingPremium = withAPIVersion 16 $ checkPatch OwnDomain "meetingsPremium" disabledLocked
 
 testMeetingPremium :: (HasCallStack) => APIAccess -> App ()
 testMeetingPremium access =
-  mkFeatureTests "meetingsPremium"
+  withAPIVersion 16
+    $ mkFeatureTests "meetingsPremium"
     & addUpdate enabled
     & runFeatureTests OwnDomain access
+
+-- | WPB-26771: the public meetingsPremium endpoints are gated at v17 (404)
+-- while remaining available through v16. Only the v16 GET is asserted here:
+-- v16 PUT success is covered by 'testMeetingPremium' (whose runFeatureTests
+-- unlocks the feature first), and a public PUT in this test would 409
+-- feature-locked against the default enabled+locked state.
+testMeetingPremiumRemovedAtV17 :: (HasCallStack) => App ()
+testMeetingPremiumRemovedAtV17 = do
+  (owner, tid, _) <- createTeam OwnDomain 0
+  let p = joinHttpPath ["teams", tid, "features", "meetingsPremium"]
+      body = object ["status" .= "enabled", "lockStatus" .= "locked"]
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) p >>= submit "GET") $ \resp -> do
+    resp.status `shouldMatchInt` 404
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) p <&> addJSON body >>= submit "PUT") $ \resp -> do
+    resp.status `shouldMatchInt` 404
+  bindResponse (baseRequest owner Galley (ExplicitVersion 16) p >>= submit "GET") $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    resp.json %. "status" `shouldMatch` "enabled"
+    resp.json %. "lockStatus" `shouldMatch` "locked"
+
+-- | WPB-26771: the aggregate list endpoints 'GET /feature-configs' and
+-- 'GET /teams/:tid/features' are version-agnostic — like 'From'-gated features
+-- (e.g. MLS), they include every feature at every API version. Even though the
+-- dedicated meetingsPremium endpoints 404 at v17, both list endpoints keep
+-- returning the (default enabled+locked) meetingsPremium entry. This test
+-- locks that behaviour in.
+testMeetingPremiumListedAtV17 :: (HasCallStack) => App ()
+testMeetingPremiumListedAtV17 = do
+  (owner, tid, _) <- createTeam OwnDomain 0
+  let assertMeetingPremium resp = do
+        resp.status `shouldMatchInt` 200
+        mp <- resp.json %. "meetingsPremium"
+        mp %. "status" `shouldMatch` "enabled"
+        mp %. "lockStatus" `shouldMatch` "locked"
+      teamFeatures = joinHttpPath ["teams", tid, "features"]
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) "/feature-configs" >>= submit "GET") assertMeetingPremium
+  bindResponse (baseRequest owner Galley (ExplicitVersion 17) teamFeatures >>= submit "GET") assertMeetingPremium
diff --git a/integration/test/Test/FeatureFlags/MlsMigration.hs b/integration/test/Test/FeatureFlags/MlsMigration.hs
index fefd5b70068..a60cd339675 100644
--- a/integration/test/Test/FeatureFlags/MlsMigration.hs
+++ b/integration/test/Test/FeatureFlags/MlsMigration.hs
@@ -49,6 +49,101 @@ testMlsMigrationDefaults = do
       feat <- Internal.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
       feat %. "config" `shouldMatch` mlsMigrationDefaultConfig
 
+testMlsMigrationAllowManualMigration :: (HasCallStack) => App ()
+testMlsMigrationAllowManualMigration = do
+  (owner, tid, _) <- createTeam OwnDomain 0
+  void $ Public.setTeamFeatureConfig owner tid "mls" mlsEnable >>= getJSON 200
+
+  getResp0 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp0 %. "config" %. "allowManualMigration") `shouldMatch` False
+
+  patchResp0 <-
+    Internal.patchTeamFeature owner tid "mlsMigration" (object ["lockStatus" .= "unlocked"])
+      >>= getJSON 200
+  (patchResp0 %. "config" %. "allowManualMigration") `shouldMatch` False
+
+  setResp1 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig1
+      >>= getJSON 200
+  getResp1 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp1 %. "config" %. "allowManualMigration") `shouldMatch` False
+  (getResp1 %. "config") `shouldMatch` (setResp1 %. "config")
+
+  setResp2 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig2
+      >>= getJSON 200
+  getResp2 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp2 %. "config" %. "allowManualMigration") `shouldMatch` True
+  (getResp2 %. "config") `shouldMatch` (setResp2 %. "config")
+
+  let patchWithoutField =
+        object
+          [ "status" .= "enabled",
+            "config"
+              .= object
+                [ "startTime" .= "2030-01-01T00:00:00Z"
+                ]
+          ]
+  setResp3 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" patchWithoutField
+      >>= getJSON 200
+  getResp3 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp3 %. "config" %. "allowManualMigration") `shouldMatch` False
+  (getResp3 %. "config") `shouldMatch` (setResp3 %. "config")
+
+  let patchWithField =
+        object
+          [ "status" .= "enabled",
+            "config"
+              .= object
+                [ "startTime" .= "2030-01-01T00:00:00Z",
+                  "allowManualMigration" .= True
+                ]
+          ]
+  setResp4 <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" patchWithField
+      >>= getJSON 200
+  getResp4 <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+  (getResp4 %. "config" %. "allowManualMigration") `shouldMatch` True
+  (getResp4 %. "config") `shouldMatch` (setResp4 %. "config")
+
+-- | PUT replaces the whole config; it does not merge omitted fields with the
+-- previously stored value. This pins down that assumption so that future
+-- schema changes to individual fields (e.g. allowManualMigration) don't
+-- accidentally start relying on merge behaviour that doesn't exist.
+testMlsMigrationPutDoesNotMergeOmittedFields :: (HasCallStack) => App ()
+testMlsMigrationPutDoesNotMergeOmittedFields = do
+  (owner, tid, _) <- createTeam OwnDomain 0
+  void $ Public.setTeamFeatureConfig owner tid "mls" mlsEnable >>= getJSON 200
+  void
+    $ Internal.patchTeamFeature owner tid "mlsMigration" (object ["lockStatus" .= "unlocked"])
+    >>= getJSON 200
+
+  beforePatch <-
+    Public.setTeamFeatureConfig owner tid "mlsMigration" mlsMigrationConfig2
+      >>= getJSON 200
+  (beforePatch %. "config" %. "finaliseRegardlessAfter") `shouldMatch` "2031-10-17T00:00:00Z"
+
+  let partialConfig =
+        object
+          [ "status" .= "enabled",
+            "config"
+              .= object
+                [ "startTime" .= "2030-01-01T00:00:00Z"
+                ]
+          ]
+  void
+    $ Public.setTeamFeatureConfig owner tid "mlsMigration" partialConfig
+    >>= getJSON 200
+  afterPatch <- Public.getTeamFeature owner tid "mlsMigration" >>= getJSON 200
+
+  -- omitted fields are dropped, not carried over from mlsMigrationConfig2
+  assertFieldMissing afterPatch "config.finaliseRegardlessAfter"
+
+  -- allowManualMigration is always rendered (never omitted); reset to its
+  -- default of False, not merged from mlsMigrationConfig2's True
+  (afterPatch %. "config" %. "allowManualMigration") `shouldMatch` False
+
 mlsEnableConfig :: Value
 mlsEnableConfig =
   object
@@ -70,7 +165,8 @@ mlsMigrationDefaultConfig :: Value
 mlsMigrationDefaultConfig =
   object
     [ "startTime" .= "2029-05-16T10:11:12.123Z",
-      "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z"
+      "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z",
+      "allowManualMigration" .= False
     ]
 
 mlsMigrationConfig1 :: Value
@@ -80,7 +176,8 @@ mlsMigrationConfig1 =
       "config"
         .= object
           [ "startTime" .= "2029-05-16T10:11:12.123Z",
-            "finaliseRegardlessAfter" .= "2030-10-17T00:00:00Z"
+            "finaliseRegardlessAfter" .= "2030-10-17T00:00:00Z",
+            "allowManualMigration" .= False
           ]
     ]
 
@@ -91,6 +188,7 @@ mlsMigrationConfig2 =
       "config"
         .= object
           [ "startTime" .= "2030-05-16T10:11:12.123Z",
-            "finaliseRegardlessAfter" .= "2031-10-17T00:00:00Z"
+            "finaliseRegardlessAfter" .= "2031-10-17T00:00:00Z",
+            "allowManualMigration" .= True
           ]
     ]
diff --git a/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs b/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs
index 72febf3f84e..9bdd9ed2a8a 100644
--- a/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs
+++ b/integration/test/Test/FeatureFlags/PreventAdminlessGroups.hs
@@ -80,12 +80,12 @@ testPreventAdminlessGroupsPutV16AcceptsLegacyTimeoutFields = do
       resp.json `shouldMatch` canonicalPreventAdminlessGroupsFeature
   checkFeature "preventAdminlessGroups" owner tid canonicalPreventAdminlessGroupsFeature
 
-testPreventAdminlessGroupsPutV17AcceptsDurationTimeoutFields :: (HasCallStack) => App ()
-testPreventAdminlessGroupsPutV17AcceptsDurationTimeoutFields = do
+testPreventAdminlessGroupsPutV18AcceptsDurationTimeoutFields :: (HasCallStack) => App ()
+testPreventAdminlessGroupsPutV18AcceptsDurationTimeoutFields = do
   (owner, tid, _) <- createTeam OwnDomain 0
   bindResponse
     ( Public.setTeamFeatureConfigVersioned
-        (ExplicitVersion 17)
+        (ExplicitVersion 18)
         owner
         tid
         "preventAdminlessGroups"
diff --git a/integration/test/Test/FeatureFlags/Util.hs b/integration/test/Test/FeatureFlags/Util.hs
index ec1b3a17096..17a994de737 100644
--- a/integration/test/Test/FeatureFlags/Util.hs
+++ b/integration/test/Test/FeatureFlags/Util.hs
@@ -57,6 +57,9 @@ disabled = object ["lockStatus" .= "unlocked", "status" .= "disabled", "ttl" .=
 disabledLocked :: Value
 disabledLocked = object ["lockStatus" .= "locked", "status" .= "disabled", "ttl" .= "unlimited"]
 
+enabledLocked :: Value
+enabledLocked = object ["lockStatus" .= "locked", "status" .= "enabled", "ttl" .= "unlimited"]
+
 enabled :: Value
 enabled = object ["lockStatus" .= "unlocked", "status" .= "enabled", "ttl" .= "unlimited"]
 
@@ -180,7 +183,8 @@ defAllFeatures =
             "config"
               .= object
                 [ "startTime" .= "2029-05-16T10:11:12.123Z",
-                  "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z"
+                  "finaliseRegardlessAfter" .= "2029-10-17T00:00:00Z",
+                  "allowManualMigration" .= False
                 ]
           ],
       "enforceFileDownloadLocation"
@@ -246,8 +250,8 @@ defAllFeatures =
                 ]
           ],
       "meetings" .= enabled,
-      "meetingsPremium" .= disabledLocked,
-      "backgroundEffects" .= disabledLocked,
+      "meetingsPremium" .= enabledLocked,
+      "backgroundEffects" .= enabledLocked,
       "preventAdminlessGroups"
         .= object
           [ "lockStatus" .= "unlocked",
diff --git a/integration/test/Test/Federator.hs b/integration/test/Test/Federator.hs
index 5fd237ca88b..cda48844f8d 100644
--- a/integration/test/Test/Federator.hs
+++ b/integration/test/Test/Federator.hs
@@ -50,18 +50,22 @@ testFederatorMetricsExternal = runFederatorMetrics federatorExternal
 
 testFederatorNumRequestsMetrics :: (HasCallStack) => App ()
 testFederatorNumRequestsMetrics = do
-  u1 <- randomUser OwnDomain def
-  u2 <- randomUser OtherDomain def
-  incomingBefore <- getMetric parseIncomingRequestCount OtherDomain OwnDomain
-  outgoingBefore <- getMetric parseOutgoingRequestCount OwnDomain OtherDomain
-  bindResponse (searchContacts u1 (u2 %. "name") OtherDomain) $ \resp ->
-    resp.status `shouldMatchInt` 200
-  incomingAfter <- getMetric parseIncomingRequestCount OtherDomain OwnDomain
-  outgoingAfter <- getMetric parseOutgoingRequestCount OwnDomain OtherDomain
-  assertBool "Incoming requests count should have increased by at least 2" $ incomingAfter >= incomingBefore + 2
-  assertBool "Outgoing requests count should have increased by at least 2" $ outgoingAfter >= outgoingBefore + 2
+  startDynamicBackends [def, def] $ \[d1, d2] -> do
+    u1 <- randomUser d1 def
+    u2 <- randomUser d2 def
+    incomingBefore <- getMetric parseIncomingRequestCount d2 d1
+    outgoingBefore <- getMetric parseOutgoingRequestCount d1 d2
+    bindResponse (searchContacts u1 (u2 %. "name") d2) $ \resp ->
+      resp.status `shouldMatchInt` 200
+    -- Metrics are updated asynchronously in the federator, so we need to
+    -- poll until they reflect the requests triggered by searchContacts.
+    eventually $ do
+      incomingAfter <- getMetric parseIncomingRequestCount d2 d1
+      outgoingAfter <- getMetric parseOutgoingRequestCount d1 d2
+      assertBool "Incoming requests count should have increased by at least 2" $ incomingAfter >= incomingBefore + 2
+      assertBool "Outgoing requests count should have increased by at least 2" $ outgoingAfter >= outgoingBefore + 2
   where
-    getMetric :: (Text -> Parser Integer) -> Domain -> Domain -> App Integer
+    getMetric :: (Text -> Parser Integer) -> String -> String -> App Integer
     getMetric p domain origin = do
       m <- getMetrics domain federatorInternal
       d <- cs <$> asString origin
diff --git a/integration/test/Test/LegalHold.hs b/integration/test/Test/LegalHold.hs
index 7da2c2dfa24..2fc8629bb80 100644
--- a/integration/test/Test/LegalHold.hs
+++ b/integration/test/Test/LegalHold.hs
@@ -253,11 +253,13 @@ testLHClaimKeys approvedOrPending testmode = do
       LHApproved -> approveLegalHoldDevice ltid (lmem %. "qualified_id") defPassword >>= assertSuccess
       LHPending -> pure ()
 
+    pks <- getPrekeys 10
+    lpk <- getLastPrekey
     let addc caps = addClient pmem (settings caps) >>= assertSuccess
         settings caps =
           def
-            { prekeys = Just $ take 10 somePrekeysRendered,
-              lastPrekey = Just $ head someLastPrekeysRendered,
+            { prekeys = Just pks,
+              lastPrekey = Just lpk,
               acapabilities = caps
             }
      in addc $ Just ["legalhold-implicit-consent"]
diff --git a/integration/test/Test/MLS/KeyPackage.hs b/integration/test/Test/MLS/KeyPackage.hs
index 2ca5d2c4c00..7c58110921f 100644
--- a/integration/test/Test/MLS/KeyPackage.hs
+++ b/integration/test/Test/MLS/KeyPackage.hs
@@ -5,6 +5,7 @@
 module Test.MLS.KeyPackage where
 
 import API.Brig
+import API.BrigInternal
 import MLS.Util
 import SetupHelpers
 import Testlib.Prelude
@@ -27,13 +28,27 @@ testDeleteKeyPackages = do
 
 testClaimKeyPackagesUserDeleted :: App ()
 testClaimKeyPackagesUserDeleted = do
-  (_, _, [alice]) <- createTeam OwnDomain 2
-  alice1 <- createMLSClient def alice
+  (owner, _, [alice]) <- createTeam OwnDomain 2
   API.Brig.deleteUser alice >>= assertSuccess
-  bindResponse (claimKeyPackages def alice1 alice) $ \resp -> do
+  bindResponse (claimKeyPackages def owner alice) $ \resp -> do
+    resp.status `shouldMatchInt` 400
+    resp.json %. "label" `shouldMatch` "invalid-user"
+
+testClaimKeyPackagesUserSuspended :: App ()
+testClaimKeyPackagesUserSuspended = do
+  (owner, _, [alice]) <- createTeam OwnDomain 2
+  API.BrigInternal.setAccountStatus alice "suspended" >>= assertSuccess
+  bindResponse (claimKeyPackages def owner alice) $ \resp -> do
     resp.status `shouldMatchInt` 400
     resp.json %. "label" `shouldMatch` "invalid-user"
 
+testClaimKeyPackagesEphemeralUser :: App ()
+testClaimKeyPackagesEphemeralUser = do
+  user <- randomUser OwnDomain def
+  tempUser <- ephemeralUser OwnDomain
+  bindResponse (claimKeyPackages def user tempUser) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+
 testKeyPackageMultipleCiphersuites :: App ()
 testKeyPackageMultipleCiphersuites = do
   let suite = def
diff --git a/integration/test/Test/Meetings.hs b/integration/test/Test/Meetings.hs
index 6f60057929c..8c3f231bd6e 100644
--- a/integration/test/Test/Meetings.hs
+++ b/integration/test/Test/Meetings.hs
@@ -1,5 +1,22 @@
 {-# OPTIONS_GHC -Wno-ambiguous-fields #-}
 
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Meetings where
 
 import API.Galley
@@ -9,31 +26,15 @@ import qualified Data.Text as Text
 import qualified Data.Text.Encoding as Text
 import Data.Time.Clock
 import qualified Data.Time.Format as Time
+import Data.Time.Format.ISO8601 (iso8601ParseM)
 import MLS.Util
-import Notifications (isConvCreateMeetingNotif)
+import Notifications (isConvCreateMeetingNotif, isConvDeleteMeetingNotif, isMeetingCreateNotif, isMeetingDeleteNotif, isMeetingMemberAddNotif, isMeetingUpdateNotif, isMemberJoinNotif, isWelcomeNotif)
 import SetupHelpers
 import System.Timeout (timeout)
 import Testlib.Prelude
 import Text.Regex.TDFA ((=~))
 import UnliftIO.Concurrent (threadDelay)
 
--- Helper to extract meetingId and domain from a meeting JSON object
-getMeetingIdAndDomain :: (HasCallStack) => Value -> App (String, String)
-getMeetingIdAndDomain meeting = do
-  meetingId <- meeting %. "qualified_id" %. "id" >>= asString
-  domain <- meeting %. "qualified_id" %. "domain" >>= asString
-  pure (meetingId, domain)
-
--- Helper to create a default new meeting JSON object
-defaultMeetingJson :: String -> UTCTime -> UTCTime -> [String] -> Value
-defaultMeetingJson title startTime endTime invitedEmails =
-  object
-    [ "title" .= title,
-      "start_time" .= startTime,
-      "end_time" .= endTime,
-      "invited_emails" .= invitedEmails
-    ]
-
 testMeetingCreate :: (HasCallStack) => App ()
 testMeetingCreate = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
@@ -48,12 +49,20 @@ testMeetingCreate = do
       resp <- postMeetings owner newMeeting
       assertSuccess resp
       void $ awaitMatch isConvCreateMeetingNotif ws
-      getJSON 201 resp
+      m <- getJSON 201 resp
+      -- the creator's other client connection (this websocket) now receives meeting.create
+      void $ awaitMatch isMeetingCreateNotif ws
+      pure m
 
   meeting %. "title" `shouldMatch` ("Team Standup" :: String)
   meeting %. "qualified_creator" %. "id" `shouldMatch` ownerId
   meeting %. "invited_emails" `shouldMatch` (["alice@example.com", "bob@example.com"] :: [String])
 
+  -- The full conversation is returned alongside the legacy field
+  assertConversationMatchesLegacy meeting
+
+  meeting %. "conversation" %. "access" `shouldMatchSet` ["invite", "code"]
+
   -- Verify fetching the meeting
   (meetingId, domain) <- getMeetingIdAndDomain meeting
   r2 <- getMeeting owner domain meetingId
@@ -84,15 +93,83 @@ testMeetingMLSAddParticipant = do
   convId <- objConvId conv
   createGroup def alice1 convId
 
-  -- Before the fix, this add commit fails with 403 access-denied at the
-  -- server (getJSON 201 below throws). After the fix it succeeds.
-  void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle
+  memberAddNotif <-
+    withWebSocket bob $ \ws -> do
+      void $ createAddCommit alice1 convId [bob] >>= sendAndConsumeCommitBundle
+      let isMeetingAddSequenceNotif notif = do
+            isMemberJoin <- isMemberJoinNotif notif
+            isMeetingMemberAdd <- isMeetingMemberAddNotif notif
+            isWelcome <- isWelcomeNotif notif
+            pure $ isMemberJoin || isMeetingMemberAdd || isWelcome
+      sequenceNotifs <- replicateM 3 (awaitMatch isMeetingAddSequenceNotif ws)
+      sequenceTypes <- for sequenceNotifs $ \notif -> notif %. "payload.0.type" >>= asString
+      sequenceTypes
+        `shouldMatchSet` [ "conversation.member-join",
+                           "meeting.member-add",
+                           "conversation.mls-welcome"
+                         ]
+      case [n | (t, n) <- zip sequenceTypes sequenceNotifs, t == "meeting.member-add"] of
+        (notif : _) -> pure notif
+        [] -> assertFailure "expected a meeting.member-add notification in the add sequence"
+
+  assertMeetingNotif memberAddNotif (meeting %. "qualified_id")
+  memberAddNotif %. "payload.0.qualified_conversation" `shouldMatch` convQid
 
   bindResponse (getConversation alice convQid) $ \res -> do
     res.status `shouldMatchInt` 200
     (length <$> (res.json %. "members.others" & asList)) `shouldMatchInt` 1
     res.json %. "members.others.0.qualified_id" `shouldMatch` objQidObject bob
 
+-- | Helper to extract meetingId and domain from a meeting JSON object
+getMeetingIdAndDomain :: (HasCallStack) => Value -> App (String, String)
+getMeetingIdAndDomain meeting = do
+  meetingId <- meeting %. "qualified_id" %. "id" >>= asString
+  domain <- meeting %. "qualified_id" %. "domain" >>= asString
+  pure (meetingId, domain)
+
+-- | On create/update responses, the full @conversation@ object is returned
+-- alongside the legacy @qualified_conversation@ field. This asserts that both
+-- refer to the same conversation.
+assertConversationMatchesLegacy :: (HasCallStack) => Value -> App ()
+assertConversationMatchesLegacy meeting = do
+  convId <- meeting %. "conversation" %. "qualified_id"
+  legacyConvId <- meeting %. "qualified_conversation"
+  convId `shouldMatch` legacyConvId
+
+-- | Assert a meeting lifecycle notification carries the meeting's qualified id flat
+-- at payload.0.qualified_id and has NO "data" wrapper (proves the no-nesting
+-- contract).
+assertMeetingNotif ::
+  (HasCallStack, MakesValue notif, MakesValue qid) =>
+  notif ->
+  qid ->
+  App ()
+assertMeetingNotif notif qid = do
+  notif %. "payload.0.qualified_id" `shouldMatch` qid
+  assertFieldMissing notif "payload.0.data"
+
+-- | Helper to create a default new meeting JSON object
+defaultMeetingJson :: String -> UTCTime -> UTCTime -> [String] -> Value
+defaultMeetingJson title startTime endTime invitedEmails =
+  object
+    [ "title" .= title,
+      "start_time" .= startTime,
+      "end_time" .= endTime,
+      "tzid" .= ("Europe/Berlin" :: String),
+      "invited_emails" .= invitedEmails
+    ]
+
+-- | Legacy (V15/V16) meeting JSON: carries @end_time@ and no @tzid@ (V17 adds
+-- @tzid@). Used to exercise the legacy endpoints in interop tests.
+defaultMeetingJsonLegacy :: String -> UTCTime -> UTCTime -> [String] -> Value
+defaultMeetingJsonLegacy title startTime endTime invitedEmails =
+  object
+    [ "title" .= title,
+      "start_time" .= startTime,
+      "end_time" .= endTime,
+      "invited_emails" .= invitedEmails
+    ]
+
 testMeetingGetNotFound :: (HasCallStack) => App ()
 testMeetingGetNotFound = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
@@ -100,55 +177,76 @@ testMeetingGetNotFound = do
 
   getMeeting owner "example.com" fakeMeetingId >>= assertLabel 404 "meeting-not-found"
 
--- Test that personal (non-team) users create trial meetings
-testMeetingCreatePersonalUserTrial :: (HasCallStack) => App ()
-testMeetingCreatePersonalUserTrial = do
-  personalUser <- randomUser OwnDomain def
+-- Test that disabled MeetingsConfig feature blocks creation
+testMeetingsConfigDisabledBlocksCreate :: (HasCallStack) => App ()
+testMeetingsConfigDisabledBlocksCreate = do
+  (owner, tid, _members) <- createTeam OwnDomain 1
+
+  -- Disable the MeetingsConfig feature
+  let firstMeeting = object ["status" .= "disabled", "lockStatus" .= "unlocked"]
+  I.setTeamFeatureConfig owner tid "meetings" firstMeeting >>= assertStatus 200
+
+  -- Try to create a meeting - should fail
   now <- liftIO getCurrentTime
   let startTime = addUTCTime 3600 now
       endTime = addUTCTime 7200 now
-      newMeeting = defaultMeetingJson "Personal Meeting" startTime endTime []
-
-  r <- postMeetings personalUser newMeeting
-  assertSuccess r
+      newMeeting = defaultMeetingJson "Team Standup" startTime endTime []
 
-  meeting <- getJSON 201 r
-  meeting %. "trial" `shouldMatch` True
+  postMeetings owner newMeeting >>= assertLabel 403 "invalid-op"
 
--- Test that paying team members create non-trial meetings
-testMeetingCreatePayingTeamNonTrial :: (HasCallStack) => App ()
-testMeetingCreatePayingTeamNonTrial = do
+-- | Read endpoints (@GET /meetings/list@, @GET /meetings/{domain}/{id}@) treat a
+-- team with the @meetings@ feature disabled as "no meetings" (list -> @200 []@,
+-- get -> @404 meeting-not-found@) instead of @403 invalid-op@, while write
+-- endpoints (@POST /meetings@) keep the hard gate and still return @403
+-- invalid-op@. See WPB-27329.
+testMeetingsReadsWhenDisabled :: (HasCallStack) => App ()
+testMeetingsReadsWhenDisabled = do
   (owner, tid, _members) <- createTeam OwnDomain 1
-
-  let firstMeeting = object ["status" .= "enabled"]
-  I.setTeamFeatureLockStatus owner tid "meetingsPremium" "unlocked"
-  I.setTeamFeatureConfig owner tid "meetingsPremium" firstMeeting >>= assertStatus 200
-
   now <- liftIO getCurrentTime
   let startTime = addUTCTime 3600 now
       endTime = addUTCTime 7200 now
-      newMeeting = defaultMeetingJson "Paying Team Meeting" startTime endTime []
+      newMeeting = defaultMeetingJson "test meeting" startTime endTime []
 
-  r <- postMeetings owner newMeeting
-  assertSuccess r
+  -- Enabled (default): create a meeting to use as the read target later.
+  meeting <- postMeetings owner newMeeting >>= getJSON 201
+  (meetingId, domain) <- getMeetingIdAndDomain meeting
 
-  meeting <- getJSON 201 r
-  meeting %. "trial" `shouldMatch` False
+  -- Positive control while enabled: the created meeting is directly findable.
+  getMeeting owner domain meetingId >>= assertStatus 200
 
--- Test that disabled MeetingsConfig feature blocks creation
-testMeetingsConfigDisabledBlocksCreate :: (HasCallStack) => App ()
-testMeetingsConfigDisabledBlocksCreate = do
-  (owner, tid, _members) <- createTeam OwnDomain 1
+  -- Disable the meetings feature for the team.
+  let disabled = object ["status" .= "disabled", "lockStatus" .= "unlocked"]
+  I.setTeamFeatureConfig owner tid "meetings" disabled >>= assertStatus 200
 
-  -- Disable the MeetingsConfig feature
-  let firstMeeting = object ["status" .= "disabled", "lockStatus" .= "unlocked"]
-  I.setTeamFeatureConfig owner tid "meetings" firstMeeting >>= assertStatus 200
+  -- Read paths treat a disabled feature as "no meetings": list -> 200 [], get -> 404.
+  getMeetingsList owner `bindResponse` \resp -> do
+    resp.status `shouldMatchInt` 200
+    meetings <- resp.json & asList
+    shouldBeEmpty meetings
 
-  -- Try to create a meeting - should fail
+  getMeeting owner domain meetingId >>= assertLabel 404 "meeting-not-found"
+
+  -- Write paths keep the hard gate: create -> 403 invalid-op (unchanged behavior).
+  postMeetings owner newMeeting >>= assertLabel 403 "invalid-op"
+
+  -- Re-enable: the previously-created meeting is readable again.
+  let enabled = object ["status" .= "enabled", "lockStatus" .= "unlocked"]
+  I.setTeamFeatureConfig owner tid "meetings" enabled >>= assertStatus 200
+
+  listResp2 <- getMeetingsList owner
+  assertSuccess listResp2
+  meetings2 <- listResp2.json & asList
+  fetchedIds <- forM meetings2 $ \m -> m %. "qualified_id" %. "id" >>= asString
+  (meetingId `elem` fetchedIds) `shouldMatch` True
+
+-- Test that creating a meeting with a start time in the past is rejected
+testMeetingCreatePastStartTime :: (HasCallStack) => App ()
+testMeetingCreatePastStartTime = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
   now <- liftIO getCurrentTime
-  let startTime = addUTCTime 3600 now
-      endTime = addUTCTime 7200 now
-      newMeeting = defaultMeetingJson "Team Standup" startTime endTime []
+  let startTime = addUTCTime (negate 3600) now
+      endTime = addUTCTime 3600 now
+      newMeeting = defaultMeetingJson "Past Meeting" startTime endTime []
 
   postMeetings owner newMeeting >>= assertLabel 403 "invalid-op"
 
@@ -170,6 +268,7 @@ testMeetingRecurrence = do
           [ "title" .= "Daily Standup with Recurrence",
             "start_time" .= startTime,
             "end_time" .= endTime,
+            "tzid" .= ("Europe/Berlin" :: String),
             "recurrence" .= recurrence,
             "invited_emails" .= ["charlie@example.com"]
           ]
@@ -192,8 +291,12 @@ testMeetingRecurrence = do
             "recurrence" .= updatedRecurrence
           ]
 
-  r2 <- putMeeting owner domain meetingId updatedMeeting
-  assertSuccess r2
+  r2 <- withWebSocket owner $ \ws -> do
+    resp <- putMeeting owner domain meetingId updatedMeeting
+    assertSuccess resp
+    -- the creator's other client connection (this websocket) now receives meeting.update
+    void $ awaitMatch isMeetingUpdateNotif ws
+    pure resp
 
   updated <- getJSON 200 r2
   updated %. "title" `shouldMatch` ("Updated Standup" :: String)
@@ -201,6 +304,9 @@ testMeetingRecurrence = do
   recurrence' %. "frequency" `shouldMatch` "weekly"
   recurrence' %. "interval" `shouldMatchInt` 2
 
+  -- The full conversation is still returned on update
+  assertConversationMatchesLegacy updated
+
 testMeetingUpdateNotFound :: (HasCallStack) => App ()
 testMeetingUpdateNotFound = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
@@ -240,6 +346,32 @@ testMeetingUpdateUnauthorized = do
 
   putMeeting otherUser domain meetingId update >>= assertStatus 404
 
+-- | WPB-28272: PUT /meetings/{domain}/{id} accepts an optional @tzid@ and
+-- updates the stored time zone; omitting it (as legacy clients do) keeps the
+-- stored value; an invalid tzid is rejected at decode time.
+testMeetingUpdateTzid :: (HasCallStack) => App ()
+testMeetingUpdateTzid = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
+  now <- liftIO getCurrentTime
+  let newMeeting = defaultMeetingJson "Tzid Meeting" (addUTCTime 3600 now) (addUTCTime 7200 now) []
+  meeting <- postMeetings owner newMeeting >>= getJSON 201
+  (meetingId, domain) <- getMeetingIdAndDomain meeting
+  meeting %. "tzid" `shouldMatch` ("Europe/Berlin" :: String)
+
+  updated <- putMeeting owner domain meetingId (object ["tzid" .= ("America/New_York" :: String)]) >>= getJSON 200
+  updated %. "tzid" `shouldMatch` ("America/New_York" :: String)
+  updated %. "title" `shouldMatch` ("Tzid Meeting" :: String)
+
+  fetched <- getMeeting owner domain meetingId >>= getJSON 200
+  fetched %. "tzid" `shouldMatch` ("America/New_York" :: String)
+
+  putMeeting owner domain meetingId (object ["title" .= ("Renamed" :: String)]) >>= assertStatus 200
+  fetched2 <- getMeeting owner domain meetingId >>= getJSON 200
+  fetched2 %. "tzid" `shouldMatch` ("America/New_York" :: String)
+  fetched2 %. "title" `shouldMatch` ("Renamed" :: String)
+
+  putMeeting owner domain meetingId (object ["tzid" .= ("not-a-zone" :: String)]) >>= assertLabel 400 "bad-request"
+
 testMeetingListEmpty :: (HasCallStack) => App ()
 testMeetingListEmpty = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
@@ -423,6 +555,7 @@ testMeetingDelete = do
           [ "title" .= "Team Standup",
             "start_time" .= startTime,
             "end_time" .= endTime,
+            "tzid" .= ("Europe/Berlin" :: String),
             "invited_emails" .= ([] :: [String]),
             "recurrence" .= recurrence
           ]
@@ -430,9 +563,74 @@ testMeetingDelete = do
   assertSuccess r1
   meeting <- getJSON 201 r1
   (meetingId, domain) <- getMeetingIdAndDomain meeting
-  deleteMeeting owner domain meetingId >>= assertStatus 200
+  withWebSocket owner $ \ws -> do
+    deleteMeeting owner domain meetingId >>= assertStatus 200
+    void $ awaitMatch isConvDeleteMeetingNotif ws
+    -- the creator's other client connection (this websocket) now receives meeting.delete
+    void $ awaitMatch isMeetingDeleteNotif ws
   getMeeting owner domain meetingId >>= assertStatus 404
 
+-- | WPB-27907: meeting lifecycle events are delivered to all conversation
+-- members; only the originating client connection is excluded (no redundant
+-- echo), while the initiator's other client connections and all other members
+-- receive them. This test covers the non-initiator member path: a member who
+-- joined the meeting conversation after creation receives 'meeting.update' and
+-- 'meeting.delete'. The originating-client-connection exclusion is covered in
+-- 'testMeetingOriginatingConnectionExcluded'; the multi-member recipient set
+-- is covered at the unit level in "Wire.MeetingsSubsystem.InterpreterSpec".
+testMeetingLifecycleEventsDeliveredToMembers :: (HasCallStack) => App ()
+testMeetingLifecycleEventsDeliveredToMembers = do
+  (owner, _tid, [participant]) <- createTeam OwnDomain 2
+  ownerClient <- createMLSClient def owner
+  participantClient <- createMLSClient def participant
+  _ <- uploadNewKeyPackage def participantClient
+  now <- liftIO getCurrentTime
+  let startTime = addUTCTime 3600 now
+      endTime = addUTCTime 7200 now
+      newMeeting = defaultMeetingJson "Lifecycle Meeting" startTime endTime []
+
+  meeting <- postMeetings owner newMeeting >>= getJSON 201
+  (meetingId, domain) <- getMeetingIdAndDomain meeting
+
+  -- Add the second team member to the meeting conversation via MLS, so that
+  -- they are among the recipients of subsequent lifecycle events.
+  convQid <- meeting %. "qualified_conversation"
+  conv <- getConversation owner convQid >>= getJSON 200
+  convId <- objConvId conv
+  createGroup def ownerClient convId
+  void $ createAddCommit ownerClient convId [participant] >>= sendAndConsumeCommitBundle
+
+  -- The non-initiator member receives 'meeting.update'; the originating-
+  -- client-connection exclusion is asserted in 'testMeetingOriginatingConnectionExcluded'.
+  updateNotif <-
+    withWebSocket participant $ \ws -> do
+      putMeeting owner domain meetingId (object ["title" .= "Updated Lifecycle Meeting"]) >>= assertSuccess
+      awaitMatch isMeetingUpdateNotif ws
+  assertMeetingNotif updateNotif (meeting %. "qualified_id")
+
+  -- The non-initiator member receives 'meeting.delete'. The preceding
+  -- 'conversation.delete-meeting' event is expected and skipped by 'awaitMatch'.
+  deleteNotif <-
+    withWebSocket participant $ \ws -> do
+      deleteMeeting owner domain meetingId >>= assertStatus 200
+      awaitMatch isMeetingDeleteNotif ws
+  assertMeetingNotif deleteNotif (meeting %. "qualified_id")
+
+testMeetingOriginatingConnectionExcluded :: (HasCallStack) => App ()
+testMeetingOriginatingConnectionExcluded = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
+  now <- liftIO getCurrentTime
+  let newMeeting = defaultMeetingJson "Origin Conn" (addUTCTime 3600 now) (addUTCTime 7200 now) []
+  withWebSocket (owner, "conn") $ \wsOrigin ->
+    withWebSocket owner $ \wsOther -> do
+      _ <- postMeetings owner newMeeting >>= assertSuccess
+      void $ awaitMatch isMeetingCreateNotif wsOther
+      -- The originating client connection still receives conversation.create-meeting
+      -- (the conversation event is not client-connection-excluded), but it must NOT
+      -- receive meeting.create (excluded via the request's client connection "conn").
+      void $ awaitMatch isConvCreateMeetingNotif wsOrigin
+      assertNoEvent 1 wsOrigin
+
 testMeetingDeleteNotFound :: (HasCallStack) => App ()
 testMeetingDeleteNotFound = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
@@ -460,15 +658,15 @@ testMeetingCleanup = do
     -- 2 minutes timeout
     (owner, _tid, _members) <- createTeam OwnDomain 1
     now <- liftIO getCurrentTime
-    -- Create a meeting that ends now.
+    -- Create a meeting that starts now and ends 1s later.
     -- Configured retention is 0.0014 hours (~5 seconds).
     -- cutoffTime will be now' - 5s.
     -- We need end_date < cutoffTime.
-    -- If we wait 6 seconds, now' = now + 6s.
-    -- cutoffTime = now + 6s - 5s = now + 1s.
-    -- end_date (now) < cutoffTime (now + 1s).
-    let startTime = addUTCTime (negate 3600) now
-        endTime = now
+    -- If we wait 7 seconds, now' = now + 7s.
+    -- cutoffTime = now + 7s - 5s = now + 2s.
+    -- end_date (now + 1s) < cutoffTime (now + 2s).
+    let startTime = now
+        endTime = addUTCTime 1 now
         newMeeting = defaultMeetingJson "Cleanup Test" startTime endTime []
 
     r1 <- postMeetings owner newMeeting
@@ -476,8 +674,8 @@ testMeetingCleanup = do
     meeting <- getJSON 201 r1
     (meetingId, domain) <- getMeetingIdAndDomain meeting
 
-    -- Wait 6 seconds to ensure meeting is old enough
-    liftIO $ threadDelay 6_000_000
+    -- Wait 7 seconds to ensure meeting is old enough
+    liftIO $ threadDelay 7_000_000
 
     -- Wait for cleanup job to run
     waitForCleanupJob OwnDomain
@@ -525,9 +723,9 @@ testMeetingExpiration :: (HasCallStack) => App ()
 testMeetingExpiration = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
   now <- liftIO getCurrentTime
-  let startTime = addUTCTime (negate 3600) now
+  let startTime = now
       -- meetingValidityPeriodSeconds is configured to 5 seconds in galley.integration.yaml
-      endTime = now
+      endTime = addUTCTime 1 now
       newMeeting = defaultMeetingJson "Expiring Meeting" startTime endTime []
 
   r1 <- postMeetings owner newMeeting
@@ -535,11 +733,11 @@ testMeetingExpiration = do
   meeting <- getJSON 201 r1
   (meetingId, domain) <- getMeetingIdAndDomain meeting
 
-  -- Check it is accessible immediately (endDate = now, so valid until now + 5s)
+  -- Check it is accessible immediately (endTime = now+1, well within the 5s validity)
   getMeeting owner domain meetingId >>= assertStatus 200
 
-  -- Wait 6 seconds
-  liftIO $ threadDelay 6_000_000
+  -- Wait 7 seconds so endTime (now+1) is past the validity cutoff (now+7-5 = now+2)
+  liftIO $ threadDelay 7_000_000
 
   -- Check it is expired
   getMeeting owner domain meetingId >>= assertStatus 404
@@ -551,11 +749,11 @@ testMeetingListRecurringNotExpired :: (HasCallStack) => App ()
 testMeetingListRecurringNotExpired = do
   (owner, _tid, _members) <- createTeam OwnDomain 1
   now <- liftIO getCurrentTime
-  -- endTime = now: it only becomes "past" after the threadDelay below;
-  -- the recurrence window stays open for 30 days.
+  -- startTime = now, endTime = now+1: the slot only becomes "past" after the
+  -- threadDelay below; the recurrence window stays open for 30 days.
   -- meetingValidityPeriodSeconds is 5s in galley.integration.yaml.
-  let startTime = addUTCTime (negate 3600) now
-      endTime = now
+  let startTime = now
+      endTime = addUTCTime 1 now
       recurrenceUntil = Time.formatTime Time.defaultTimeLocale "%FT%TZ" $ addUTCTime (30 * nominalDay) now
       recurrence =
         object
@@ -568,6 +766,7 @@ testMeetingListRecurringNotExpired = do
           [ "title" .= "Recurring Past Meeting",
             "start_time" .= startTime,
             "end_time" .= endTime,
+            "tzid" .= ("Europe/Berlin" :: String),
             "recurrence" .= recurrence,
             "invited_emails" .= ([] :: [String])
           ]
@@ -575,8 +774,8 @@ testMeetingListRecurringNotExpired = do
   meeting <- postMeetings owner newMeeting >>= getJSON 201
   (meetingId, domain) <- getMeetingIdAndDomain meeting
 
-  -- Wait beyond the validity period so a non-recurring meeting would expire.
-  liftIO $ threadDelay 6_000_000
+  -- Wait 7s beyond the validity period so a non-recurring meeting would expire.
+  liftIO $ threadDelay 7_000_000
 
   -- Still accessible directly.
   getMeeting owner domain meetingId >>= assertStatus 200
@@ -586,3 +785,58 @@ testMeetingListRecurringNotExpired = do
   assertSuccess resp
   meetings <- resp.json & asList
   length meetings `shouldMatchInt` 1
+
+-- | A meeting created via the V17 shape (@end_time + tzid@) is visible to legacy
+-- clients (< V17) with an @end_time@; V17 reads carry @end_time@ too.
+testMeetingInteropNewToLegacy :: (HasCallStack) => App ()
+testMeetingInteropNewToLegacy = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
+  now <- liftIO getCurrentTime
+  let startTime = addUTCTime 3600 now
+      endTime = addUTCTime 3600 startTime
+      newMeeting = defaultMeetingJson "Interop New" startTime endTime []
+  meeting <- postMeetings owner newMeeting >>= getJSON 201
+  -- V17 read shape: carries end_time + tzid.
+  startV17 <- meeting %. "start_time" >>= asString
+  endV17 <- meeting %. "end_time" >>= asString
+  startT <- assertJust ("could not parse start_time: " <> startV17) $ iso8601ParseM @Maybe @UTCTime startV17
+  endT <- assertJust ("could not parse end_time: " <> endV17) $ iso8601ParseM @Maybe @UTCTime endV17
+  -- end_time - start_time == 3600s regardless of client/server clock skew; both
+  -- come back from Postgres at microsecond precision.
+  endT `shouldMatch` addUTCTime 3600 startT
+  tzid <- meeting %. "tzid" >>= asString
+  tzid `shouldMatch` ("Europe/Berlin" :: String)
+  -- A meeting whose end is 1h after start (formerly sent as "1h") still reads
+  -- back with end_time = start_time + 3600s on the legacy path.
+  let startTime2 = addUTCTime 7200 now
+      endTime2 = addUTCTime 3600 startTime2
+      newMeeting2 = defaultMeetingJson "Interop New (1h)" startTime2 endTime2 []
+  meeting2 <- postMeetings owner newMeeting2 >>= getJSON 201
+  (meetingId2, domain2) <- getMeetingIdAndDomain meeting2
+  legacy2 <- getMeetingV16 owner domain2 meetingId2 >>= getJSON 200
+  start2Str <- legacy2 %. "start_time" >>= asString
+  end2Str <- legacy2 %. "end_time" >>= asString
+  start2T <- assertJust ("could not parse start_time: " <> start2Str) $ iso8601ParseM @Maybe @UTCTime start2Str
+  end2T <- assertJust ("could not parse end_time: " <> end2Str) $ iso8601ParseM @Maybe @UTCTime end2Str
+  end2T `shouldMatch` addUTCTime 3600 start2T
+
+-- | A meeting created via the legacy shape (@end_time@) is visible to V17 clients
+-- with @end_time@ and the injected default @tzid@ (Europe/Berlin).
+testMeetingInteropLegacyToNew :: (HasCallStack) => App ()
+testMeetingInteropLegacyToNew = do
+  (owner, _tid, _members) <- createTeam OwnDomain 1
+  now <- liftIO getCurrentTime
+  let startTime = addUTCTime 3600 now
+      endTime = addUTCTime 7200 now
+      newMeeting = defaultMeetingJsonLegacy "Interop Legacy" startTime endTime []
+  meeting <- postMeetingsV16 owner newMeeting >>= getJSON 201
+  (meetingId, domain) <- getMeetingIdAndDomain meeting
+  -- V17 read shape: end_time is present, tzid is the injected default.
+  modern <- getMeeting owner domain meetingId >>= getJSON 200
+  startStr <- modern %. "start_time" >>= asString
+  endStr <- modern %. "end_time" >>= asString
+  startT <- assertJust ("could not parse start_time: " <> startStr) $ iso8601ParseM @Maybe @UTCTime startStr
+  endT <- assertJust ("could not parse end_time: " <> endStr) $ iso8601ParseM @Maybe @UTCTime endStr
+  endT `shouldMatch` addUTCTime 3600 startT
+  tzid <- modern %. "tzid" >>= asString
+  tzid `shouldMatch` ("Europe/Berlin" :: String)
diff --git a/integration/test/Test/Migration/ConversationCodes.hs b/integration/test/Test/Migration/ConversationCodes.hs
index c56fd73c9a8..5ced8701551 100644
--- a/integration/test/Test/Migration/ConversationCodes.hs
+++ b/integration/test/Test/Migration/ConversationCodes.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Migration.ConversationCodes where
 
 import API.Galley
@@ -10,10 +27,11 @@ import Test.Migration.Util (waitForMigration)
 import Testlib.Prelude
 import Testlib.ResourcePool
 
-testConversationCodesMigration :: (HasCallStack) => TaggedBool "has-password" -> App ()
-testConversationCodesMigration (TaggedBool hasPassword) = do
+testConversationCodesMigration :: (HasCallStack) => TaggedBool "has-password" -> TaggedBool "with-zHost" -> App ()
+testConversationCodesMigration (TaggedBool hasPassword) (TaggedBool withZhost) = do
   resourcePool <- asks (.resourcePool)
   let pw = if hasPassword then Just "funky password" else Nothing
+      mbZHost = if withZhost then Just "zhost.example.com" else Nothing
 
   runCodensity (acquireResources 1 resourcePool) $ \[backend] -> do
     let domain = backend.berDomain
@@ -34,10 +52,10 @@ testConversationCodesMigration (TaggedBool hasPassword) = do
       code2 <- genCode admin conv2 pw
       codeB <- genCode admin convB pw
       -- joining works
-      checkJoinAndGet admin m1 conv1 code1 pw
-      checkJoinAndGet admin m1 conv2 code2 pw
+      checkJoinAndGet admin m1 conv1 code1 mbZHost
+      checkJoinAndGet admin m1 conv2 code2 mbZHost
       -- deletion works
-      checkDelete admin m1 convA codeA pw
+      checkDelete admin m1 convA codeA mbZHost
       pure (code2, codeB)
 
     (code3, codeC) <- runCodensity (startDynamicBackend backend (conf "migration-to-postgresql" True)) $ \_ -> do
@@ -45,12 +63,12 @@ testConversationCodesMigration (TaggedBool hasPassword) = do
       code3 <- genCode admin conv3 pw
       codeC <- genCode admin convC pw
       -- joining works
-      checkJoinAndGet admin m2 conv1 code1 pw
-      checkJoinAndGet admin m2 conv2 code2 pw
-      checkJoinAndGet admin m2 conv3 code3 pw
+      checkJoinAndGet admin m2 conv1 code1 mbZHost
+      checkJoinAndGet admin m2 conv2 code2 mbZHost
+      checkJoinAndGet admin m2 conv3 code3 mbZHost
       -- deletion works
-      checkNoCode admin m1 convA codeA pw
-      checkDelete admin m1 convB codeB pw
+      checkNoCode admin m1 convA codeA mbZHost
+      checkDelete admin m1 convB codeB mbZHost
       waitForMigration domain counterName
       pure (code3, codeC)
 
@@ -59,40 +77,40 @@ testConversationCodesMigration (TaggedBool hasPassword) = do
       code4 <- genCode admin conv4 pw
       codeD <- genCode admin convD pw
       -- joining works
-      checkJoinAndGet admin m3 conv1 code1 pw
-      checkJoinAndGet admin m3 conv2 code2 pw
-      checkJoinAndGet admin m3 conv3 code3 pw
-      checkJoinAndGet admin m3 conv4 code4 pw
+      checkJoinAndGet admin m3 conv1 code1 mbZHost
+      checkJoinAndGet admin m3 conv2 code2 mbZHost
+      checkJoinAndGet admin m3 conv3 code3 mbZHost
+      checkJoinAndGet admin m3 conv4 code4 mbZHost
       -- deletion works
-      checkNoCode admin m1 convA codeA pw
-      checkNoCode admin m1 convB codeB pw
-      checkDelete admin m1 convC codeC pw
+      checkNoCode admin m1 convA codeA mbZHost
+      checkNoCode admin m1 convB codeB mbZHost
+      checkDelete admin m1 convC codeC mbZHost
       pure (code4, codeD)
 
     runCodensity (startDynamicBackend backend (conf "postgresql" False)) $ \_ -> do
       -- code generation works
       code5 <- genCode admin conv5 pw
       -- joining works
-      checkJoinAndGet admin m4 conv1 code1 pw
-      checkJoinAndGet admin m4 conv2 code2 pw
-      checkJoinAndGet admin m4 conv3 code3 pw
-      checkJoinAndGet admin m4 conv4 code4 pw
-      checkJoinAndGet admin m4 conv5 code5 pw
+      checkJoinAndGet admin m4 conv1 code1 mbZHost
+      checkJoinAndGet admin m4 conv2 code2 mbZHost
+      checkJoinAndGet admin m4 conv3 code3 mbZHost
+      checkJoinAndGet admin m4 conv4 code4 mbZHost
+      checkJoinAndGet admin m4 conv5 code5 mbZHost
       -- deletion works
-      checkNoCode admin m1 convA codeA pw
-      checkNoCode admin m1 convB codeB pw
-      checkNoCode admin m1 convC codeC pw
-      checkDelete admin m1 convD codeD pw
-      checkDelete admin m1 conv5 code5 pw
+      checkNoCode admin m1 convA codeA mbZHost
+      checkNoCode admin m1 convB codeB mbZHost
+      checkNoCode admin m1 convC codeC mbZHost
+      checkDelete admin m1 convD codeD mbZHost
+      checkDelete admin m1 conv5 code5 mbZHost
   where
-    checkJoinAndGet admin user conv code pw = do
+    checkJoinAndGet admin user conv code mbZHost = do
       joinWithCode user conv code
-      getCode admin conv pw `shouldMatch` code
-    checkDelete admin user conv (k, v) pw = do
+      getCode admin conv mbZHost `shouldMatch` code
+    checkDelete admin user conv (k, v) mbZHost = do
       assertSuccess =<< deleteConversationCode admin conv
-      checkNoCode admin user conv (k, v) pw
-    checkNoCode admin user conv (k, v) pw = do
-      assertStatus 404 =<< getConversationCode admin conv pw
+      checkNoCode admin user conv (k, v) mbZHost
+    checkNoCode admin user conv (k, v) mbZHost = do
+      assertStatus 404 =<< getConversationCode admin conv mbZHost
       bindResponse (getJoinCodeConv user k v) $ \res -> do
         res.status `shouldMatchInt` 404
         res.json %. "label" `shouldMatch` "no-conversation-code"
@@ -145,21 +163,21 @@ genCode user conv pw =
     pure (k, v)
 
 getCode :: (HasCallStack, MakesValue user, MakesValue conv) => user -> conv -> Maybe String -> App (String, String)
-getCode user conv pw =
-  bindResponse (getConversationCode user conv pw) $ \res -> do
+getCode user conv mbZHost =
+  bindResponse (getConversationCode user conv mbZHost) $ \res -> do
     payload <- getJSON 200 res
     k <- payload %. "key" & asString
     v <- payload %. "code" & asString
     pure (k, v)
 
 waitForCodeToExpire :: (MakesValue user, MakesValue conv) => user -> conv -> Maybe String -> App ()
-waitForCodeToExpire user conv pw = do
-  res <- getConversationCode user conv pw
+waitForCodeToExpire user conv mbZHost = do
+  res <- getConversationCode user conv mbZHost
   if res.status == 404
     then pure ()
     else do
       liftIO $ threadDelay 100_000
-      waitForCodeToExpire user conv pw
+      waitForCodeToExpire user conv mbZHost
 
 joinWithCode :: (HasCallStack, MakesValue user) => user -> Value -> (String, String) -> App ()
 joinWithCode user conv (k, v) =
diff --git a/integration/test/Test/Migration/DomainRegistration.hs b/integration/test/Test/Migration/DomainRegistration.hs
index 3a7915b3242..4d8a4b4a8c0 100644
--- a/integration/test/Test/Migration/DomainRegistration.hs
+++ b/integration/test/Test/Migration/DomainRegistration.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Migration.DomainRegistration (testDomainRegistrationMigration) where
 
 import qualified API.Brig as Brig
diff --git a/integration/test/Test/Migration/TeamFeatures.hs b/integration/test/Test/Migration/TeamFeatures.hs
index 31adaae39b6..33b091a5a4f 100644
--- a/integration/test/Test/Migration/TeamFeatures.hs
+++ b/integration/test/Test/Migration/TeamFeatures.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Migration.TeamFeatures where
 
 import qualified API.Galley as Public
diff --git a/integration/test/Test/Migration/Util.hs b/integration/test/Test/Migration/Util.hs
index f55db0c58f9..ba3a116b453 100644
--- a/integration/test/Test/Migration/Util.hs
+++ b/integration/test/Test/Migration/Util.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Migration.Util where
 
 import Control.Applicative
diff --git a/integration/test/Test/One2OneTeamConv.hs b/integration/test/Test/One2OneTeamConv.hs
new file mode 100644
index 00000000000..8e03a12e19c
--- /dev/null
+++ b/integration/test/Test/One2OneTeamConv.hs
@@ -0,0 +1,83 @@
+{-# OPTIONS_GHC -Wno-ambiguous-fields #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Test.One2OneTeamConv where
+
+import API.Galley
+import Notifications (isTeamMemberJoinNotif)
+import SetupHelpers
+import Testlib.Prelude
+
+-- | Team member roles that the one2one team conversation test is parametrized
+-- over (the standard non-owner roles). Each constructor is enumerated into a
+-- separate test case by the test discovery machinery, mirroring the way the
+-- original galley test invoked @testCreateOne2OneWithMembers@ once per role.
+data One2OneRole = One2OneMember | One2OnePartner
+  deriving stock (Generic, Eq, Show)
+
+-- | Map a parametrized role to the team-role string accepted by
+-- 'createTeamMember' (see @Wire.API.Team.Role@: @member@, @partner@). The
+-- original galley test covered @RoleMember@ and @RoleExternalPartner@.
+one2OneRoleName :: One2OneRole -> String
+one2OneRoleName = \case
+  One2OneMember -> "member"
+  One2OnePartner -> "partner"
+
+-- | An owner adds a second team member with the given role and creates a
+-- binding one2one team conversation with them. Ported from
+-- @services/galley/test/integration/API/Teams.hs@
+-- (@testCreateOne2OneWithMembers@).
+testCreateOne2OneWithMembers :: (HasCallStack) => One2OneRole -> App ()
+testCreateOne2OneWithMembers memberRole = do
+  (owner, tid, []) <- createTeam OwnDomain 1
+  teamMember <-
+    withWebSockets [owner] $ \[wsOwner] -> do
+      m <- createTeamMember owner def {role = one2OneRoleName memberRole}
+      -- The owner is notified of the new member joining the team. This mirrors
+      -- @checkTeamMemberJoin@ in the galley test suite.
+      memberJoin <- awaitMatch isTeamMemberJoinNotif wsOwner
+      memberJoin %. "payload.0.team" `shouldMatch` tid
+      memberJoin %. "payload.0.data.user" `shouldMatch` objId m
+      -- The original test additionally asserts a @team.update@ event via the
+      -- galley SQS team-event queue (@assertTeamUpdate tid 2 [owner]@). That
+      -- queue is galley-test-specific and has no websocket / Testlib
+      -- equivalent (galley only pushes @team.member-join@ over websockets on a
+      -- member join), so we verify the updated team membership directly.
+      bindResponse (getTeamMembers owner tid) $ \resp -> do
+        resp.status `shouldMatchInt` 200
+        members <- resp.json %. "members" >>= asList
+        length members `shouldMatchInt` 2
+      pure m
+  -- Creating the one2one team conversation is eventually consistent: retry
+  -- until it returns 201.
+  eventually $ postOne2OneConversation owner teamMember tid "chit-chat" >>= assertStatus 201
+  -- Recreating the one2one is a no-op and returns 200.
+  bindResponse (postOne2OneConversation owner teamMember tid "chit-chat") $ \resp ->
+    resp.status `shouldMatchInt` 200
+
+-- | Two owners each create their own binding team. Attempting to create a
+-- one2one team conversation with a member of a different (binding) team fails
+-- with @non-binding-team-members@. Ported from
+-- @testCreateOne2OneFailForNonTeamMembers@.
+testCreateOne2OneFailForNonTeamMembers :: (HasCallStack) => App ()
+testCreateOne2OneFailForNonTeamMembers = do
+  (owner1, tid1, []) <- createTeam OwnDomain 1
+  (owner2, _tid2, []) <- createTeam OwnDomain 1
+  postOne2OneConversation owner1 owner2 tid1 "chit-chat"
+    >>= assertLabel 403 "non-binding-team-members"
diff --git a/integration/test/Test/Provider.hs b/integration/test/Test/Provider.hs
index a76b5ab37dd..8136dc81832 100644
--- a/integration/test/Test/Provider.hs
+++ b/integration/test/Test/Provider.hs
@@ -120,7 +120,7 @@ testProviderSearchWhitelist =
       (owner, tid, [user]) <- createTeam domain 2
       provider <- setupProvider owner def {newProviderPassword = Just defPassword}
       pid <- asString $ provider %. "id"
-      namePrefix <- randomString 10
+      namePrefix <- randomAlphaString 10
 
       services <-
         forM (taggedServiceNames namePrefix) $ \(name, tags) -> do
diff --git a/integration/test/Test/Search.hs b/integration/test/Test/Search.hs
index 65a899179e9..a0986828555 100644
--- a/integration/test/Test/Search.hs
+++ b/integration/test/Test/Search.hs
@@ -578,6 +578,29 @@ testUserSearchable = do
         docs <- resp.json %. "documents" >>= asList
         f docs
 
+testStealthUsersWithFederation :: App ()
+testStealthUsersWithFederation = do
+  ownDomain <- asString OwnDomain
+  otherDomain <- asString OtherDomain
+  void $ BrigI.createFedConn OwnDomain (BrigI.FedConn otherDomain "full_search" Nothing)
+  void $ BrigI.createFedConn OtherDomain (BrigI.FedConn ownDomain "full_search" Nothing)
+
+  (searcher, _, _) <- createTeam OwnDomain 1
+  (owner, tid, searchee : _) <- createTeam OtherDomain 2
+  searcheeId <- objId searchee
+  let searchTerm = "stealth-federation-user"
+
+  assertSuccess =<< GalleyI.setTeamFeatureStatus OtherDomain tid "searchVisibilityInbound" "enabled"
+  BrigP.putSelf searchee (def {BrigP.name = Just searchTerm}) >>= assertSuccess
+  BrigI.refreshIndex OtherDomain
+
+  assertCanFind searcher searchee searchTerm OtherDomain
+
+  BrigP.setUserSearchable owner searcheeId False >>= assertSuccess
+  BrigI.refreshIndex OtherDomain
+
+  assertCannotFind searcher searchee searchTerm OtherDomain
+
 testSuspendedUserSearch :: (HasCallStack) => App ()
 testSuspendedUserSearch = do
   [searcher, searchee] <- replicateM 2 $ randomUser OwnDomain def
diff --git a/integration/test/Test/Spar.hs b/integration/test/Test/Spar.hs
index 1210b18a8a9..7d45322967a 100644
--- a/integration/test/Test/Spar.hs
+++ b/integration/test/Test/Spar.hs
@@ -37,11 +37,13 @@ import Data.String.Conversions (cs)
 import qualified Data.Text as ST
 import qualified Data.UUID as UUID
 import Data.UUID.V4 (nextRandom)
+import qualified Data.Vector as V
 import qualified SAML2.WebSSO as SAML
 import qualified SAML2.WebSSO.Test.MockResponse as SAML
 import qualified SAML2.WebSSO.Test.Util as SAML
 import qualified SAML2.WebSSO.XML as SAMLXML
 import SetupHelpers
+import Testlib.Assertions
 import Testlib.JSON
 import Testlib.PTest
 import Testlib.Prelude
@@ -51,6 +53,110 @@ import qualified Time.System as Hourglass
 ----------------------------------------------------------------------
 -- scim stuff
 
+testTeamInvitationWhenScimInvitationExpired :: (HasCallStack) => App ()
+testTeamInvitationWhenScimInvitationExpired = do
+  let settings =
+        def
+          { brigCfg =
+              -- timeout for both SCIM and team invitations
+              setField "optSettings.setTeamInvitationTimeout" (2 :: Int)
+                -- Controls when asynchronous cleanup removes expired SCIM pending accounts.
+                . setField "optSettings.setExpiredUserCleanupTimeout" (3600 :: Int)
+          }
+  withModifiedBackend settings $ \domain -> do
+    (owner, _tid, _) <- createTeam domain 1
+    token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+    -- Create a SCIM user and let its invitation expire. Cleanup is deliberately
+    -- delayed so the expired pending account still exists at this point.
+    email <- randomEmail
+    externalId <- randomExternalId
+    scimUser <- randomScimUserWithEmail externalId email
+    scid <- createScimUser domain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+    handle <- scimUser %. "userName" >>= asString
+
+    -- assert that the SCIM handle is claimed
+    putHandle owner handle >>= assertStatus 409
+
+    -- Wait until the SCIM invitation has expired.
+    eventually $ getInvitationByEmail domain email >>= assertStatus 404
+
+    -- Create and accept a manual team invitation for the same email. This is
+    -- expected to succeed after the expired SCIM account has been cleaned up.
+    invitation <- postInvitation owner (def {email = Just email}) >>= getJSON 201
+    code <- getInvitationCode owner invitation >>= getJSON 200 >>= (%. "code") >>= asString
+    registerUserWith domain email code "Alice" >>= assertStatus 201
+    user <- getUsersByEmail domain [email] >>= getJSON 200 >>= asList >>= assertOne
+    manualUserId <- user %. "id" >>= asString
+
+    -- The handle previously held by the SCIM account is available again.
+    putHandle user handle >>= assertSuccess
+
+    manualUser <- getUsersId domain [manualUserId] >>= getJSON 200 >>= asList >>= assertOne
+    manualUser %. "id" `shouldMatch` manualUserId
+    manualUser %. "email" `shouldMatch` email
+    manualUser %. "handle" `shouldMatch` handle
+    manualUser %. "managed_by" `shouldMatch` "wire"
+    manualUser %. "status" `shouldMatch` "active"
+
+    -- The regular internal users API filters deleted records, so it cannot
+    -- distinguish a deleted SCIM account from an account that is not found.
+    shouldBeEmpty $ getUsersId domain [scid] >>= getJSON 200 >>= asList
+
+testTeamInvitationWhenScimInvitationPending :: (HasCallStack) => App ()
+testTeamInvitationWhenScimInvitationPending = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  (otherOwner, _otherTid, _) <- createTeam OwnDomain 1
+  token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  -- Create a SCIM user; this sends a SCIM invitation that remains pending.
+  email <- randomEmail
+  externalId <- randomExternalId
+  scimUser <- randomScimUserWithEmail externalId email
+  scid <- createScimUser OwnDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  handle <- scimUser %. "userName" >>= asString
+
+  -- The SCIM invitation is still pending. A second team invitation for the
+  -- same email and team must be rejected with a conflict.
+  postInvitation owner (def {email = Just email}) >>= assertStatus 409
+
+  -- The email must still be invit-able by a different team; otherwise a
+  -- pending SCIM invitation could be used for an email-registration DoS.
+  postInvitation otherOwner (def {email = Just email}) >>= assertStatus 201
+
+  users <- getUsersIdIncludingPending OwnDomain [scid] >>= getJSON 200 >>= asList
+  user <- assertOne users
+  user %. "email" `shouldMatch` email
+  user %. "handle" `shouldMatch` handle
+  user %. "managed_by" `shouldMatch` "scim"
+  user %. "status" `shouldMatch` "pending-invitation"
+
+testTeamInvitationWhenScimAccountExists :: (HasCallStack) => App ()
+testTeamInvitationWhenScimAccountExists = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  token <- createScimToken owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  -- Create a SCIM user and accept the resulting SCIM invitation below.
+  email <- randomEmail
+  externalId <- randomExternalId
+  scimUser <- randomScimUserWithEmail externalId email
+  scid <- createScimUser OwnDomain token scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  handle <- scimUser %. "userName" >>= asString
+
+  -- Accept the SCIM invitation so the SCIM-managed account is active.
+  registerInvitedUser OwnDomain tid email
+
+  -- An active SCIM account already owns the email. A second team invitation
+  -- for the same email must therefore be rejected with a conflict.
+  postInvitation owner (def {email = Just email}) >>= assertStatus 409
+
+  users <- getUsersId OwnDomain [scid] >>= getJSON 200 >>= asList
+  user <- assertOne users
+  user %. "email" `shouldMatch` email
+  user %. "handle" `shouldMatch` handle
+  user %. "managed_by" `shouldMatch` "scim"
+  user %. "status" `shouldMatch` "active"
+
 testSparUserCreationInvitationTimeout :: (HasCallStack) => App ()
 testSparUserCreationInvitationTimeout = do
   (owner, tid, _) <- createTeam OwnDomain 1
@@ -194,6 +300,226 @@ testSparExternalIdDifferentFromEmailWithIdp = do
       subject <- u %. "sso_id.subject" >>= asString
       subject `shouldContainString` currentExtId
 
+testSparPatchEmailValuePath :: (HasCallStack) => App ()
+testSparPatchEmailValuePath = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  void $ setTeamFeatureStatus owner tid "sso" "enabled"
+  void $ registerTestIdPWithMeta owner >>= getJSON 201
+  -- Disable SAML email validation so the provisioned email is activated
+  -- directly (the IdP vouches for it), with no separate activation step.
+  void $ setTeamFeatureStatus owner tid "validateSAMLemails" "disabled"
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  extId <- randomExternalId
+  -- Exercise create-on-absent: a SAML user provisioned with no email receives
+  -- its first work email via Entra's @Add@ on @emails[type eq "work"].value@.
+  -- This user has no email yet, so the value-path filter matches nothing and
+  -- the entry is created (with @type = "work"@ taken from the filter, which is
+  -- then persisted and echoed). In-place update of an existing typed email is
+  -- covered by 'testSparPatchEmailValuePathInPlace'.
+  scimUser <- randomScimUserWith def {mkExternalId = pure extId} >>= removeField "emails"
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  newEmail <- randomEmail
+  let patchOp = scimAddPatchOp "emails[type eq \"work\"].value" newEmail
+  bindResponse (patchScimUser OwnDomain tok userId patchOp) $ \res -> do
+    res.status `shouldMatchInt` 200
+  -- The provisioned email propagates end-to-end: SCIM GET reflects it and,
+  -- with validation disabled, it is active in Brig.  'eventually' is needed
+  -- because the PATCH 200 only means spar accepted the change; provisioning
+  -- of the new email in Brig (auto-activation, since @validateSAMLemails@ is
+  -- disabled) happens asynchronously, and there is no user-facing event to
+  -- synchronize on for a SCIM-provisioned, not-yet-registered user.  This
+  -- mirrors the deprecated spar suite (specEmailValidation), which polls the
+  -- same way.
+  eventually $ do
+    checkSparGetUserAndFindByExtId OwnDomain tok extId userId $ \u -> do
+      storedEmail <- u %. "emails" >>= asList >>= assertOne
+      storedEmail %. "value" `shouldMatch` newEmail
+      storedEmail %. "type" `shouldMatch` ("work" :: String)
+    bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
+      res.status `shouldMatchInt` 200
+      u <- res.json & asList >>= assertOne
+      u %. "email" `shouldMatch` newEmail
+
+testSparPatchEmailValuePathInPlace :: (HasCallStack) => App ()
+testSparPatchEmailValuePathInPlace = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  void $ setTeamFeatureStatus owner tid "sso" "enabled"
+  void $ registerTestIdPWithMeta owner >>= getJSON 201
+  -- Disable SAML email validation so the updated email is activated directly
+  -- (the IdP vouches for it), with no separate activation step.
+  void $ setTeamFeatureStatus owner tid "validateSAMLemails" "disabled"
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  extId <- randomExternalId
+  email <- randomEmail
+  -- Provision with an explicit, non-default @type@ so the test proves the
+  -- echoed type comes from what was stored, not a hardcode.
+  scimUser <- randomScimUserWithEmailAndMeta extId email (Just "home") Nothing
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  newEmail <- randomEmail
+  let patchOp = scimAddPatchOp "emails[type eq \"home\"].value" newEmail
+  bindResponse (patchScimUser OwnDomain tok userId patchOp) $ \res -> do
+    res.status `shouldMatchInt` 200
+  -- In-place update (not create-on-absent): the value-path filter
+  -- @emails[type eq "home"]@ matches the stored entry (whose @type = "home"@
+  -- was persisted at provisioning time) and updates its @.value@. The proof
+  -- is the new value and the echoed @type = "home"@ below -- a
+  -- create-on-absent append would be collapsed back to the OLD address by
+  -- 'scimEmailsToEmailAddress', failing the value assertion.
+  --
+  -- 'eventually' for the same reason as in 'testSparPatchEmailValuePath'
+  -- above: the PATCH 200 precedes the asynchronous email re-provisioning in
+  -- Brig, and no synchronizing event exists for it.
+  eventually $ do
+    checkSparGetUserAndFindByExtId OwnDomain tok extId userId $ \u -> do
+      storedEmail <- u %. "emails" >>= asList >>= assertOne
+      storedEmail %. "value" `shouldMatch` newEmail
+      storedEmail %. "type" `shouldMatch` ("home" :: String)
+    bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
+      res.status `shouldMatchInt` 200
+      u <- res.json & asList >>= assertOne
+      u %. "email" `shouldMatch` newEmail
+
+-- | SCIM email metadata (@type@, @primary@) round-trips verbatim: spar
+-- persists what the IdP sends and echoes exactly that; users provisioned
+-- without metadata get back an email object with only @value@ (strict echo).
+testSparScimEmailMetaRoundTrip :: (HasCallStack) => App ()
+testSparScimEmailMetaRoundTrip = do
+  (owner, tid, _) <- createTeam OwnDomain 1
+  void $ setTeamFeatureStatus owner tid "sso" "enabled"
+  void $ registerTestIdPWithMeta owner >>= getJSON 201
+  -- Disable SAML email validation so provisioned/updated emails are activated
+  -- directly, with no separate activation step.
+  void $ setTeamFeatureStatus owner tid "validateSAMLemails" "disabled"
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  -- Provision with explicit metadata; GET must echo it exactly.
+  extId <- randomExternalId
+  email <- randomEmail
+  scimUser <- randomScimUserWithEmailAndMeta extId email (Just "work") (Just True)
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  let wantEmail = object ["value" .= email, "type" .= ("work" :: String), "primary" .= True]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantEmail]
+
+  -- Okta-style in-place update via a filter on `primary`; the new address
+  -- keeps the stored metadata.
+  newEmail <- randomEmail
+  let patchOp = scimAddPatchOp "emails[primary eq true].value" newEmail
+  bindResponse (patchScimUser OwnDomain tok userId patchOp) $ \res -> do
+    res.status `shouldMatchInt` 200
+  eventually $ do
+    getScimUser OwnDomain tok userId `bindResponse` \res -> do
+      res.status `shouldMatchInt` 200
+      res.json
+        %. "emails"
+        `shouldMatch` [object ["value" .= newEmail, "type" .= ("work" :: String), "primary" .= True]]
+    bindResponse (getUsersId OwnDomain [userId]) $ \res -> do
+      res.status `shouldMatchInt` 200
+      u <- res.json & asList >>= assertOne
+      u %. "email" `shouldMatch` newEmail
+
+  -- Strict echo: a user provisioned WITHOUT metadata gets back an email
+  -- object containing only @value@ -- no @type@/@primary@ keys are
+  -- synthesized.
+  extId2 <- randomExternalId
+  email2 <- randomEmail
+  scimUser2 <- randomScimUserWithEmail extId2 email2
+  userId2 <- createScimUser OwnDomain tok scimUser2 >>= getJSON 201 >>= (%. "id") >>= asString
+  getScimUser OwnDomain tok userId2 `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [object ["value" .= email2]]
+
+-- | Non-"work" email types are accepted and echoed verbatim (no normalization
+-- to "work").
+testSparScimEmailTypeNonWorkEcho :: (HasCallStack) => App ()
+testSparScimEmailTypeNonWorkEcho = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  mapM_
+    ( \ty -> do
+        extId <- randomExternalId
+        email <- randomEmail
+        scimUser <- randomScimUserWithEmailAndMeta extId email (Just ty) Nothing
+        userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+        getScimUser OwnDomain tok userId `bindResponse` \res -> do
+          res.status `shouldMatchInt` 200
+          res.json %. "emails" `shouldMatch` [object ["value" .= email, "type" .= ty]]
+    )
+    ["home", "other"]
+
+-- | RFC 7644 §3.5.2.2: removing a sub-attribute unassigns it and keeps the
+-- record; RFC 7643 §2.5 (and RFC 7644 §3.5.2): assigning @null@ is equivalent
+-- to unassignment.  Removing or nulling @emails[type eq \"work\"].type@ must
+-- not erase the email value or the @primary@ flag.
+testSparPatchEmailSubAttrRemoveNull :: (HasCallStack) => App ()
+testSparPatchEmailSubAttrRemoveNull = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+
+  extId <- randomExternalId
+  email <- randomEmail
+  scimUser <- randomScimUserWithEmailAndMeta extId email (Just "work") (Just True)
+  userId <- createScimUser OwnDomain tok scimUser >>= getJSON 201 >>= (%. "id") >>= asString
+  let typePath = "emails[type eq \"work\"].type"
+      wantFull = object ["value" .= email, "type" .= ("work" :: String), "primary" .= True]
+      wantKept = object ["value" .= email, "primary" .= True]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantFull]
+
+  -- PATCH Remove on the type sub-attribute: synchronous strict echo (the
+  -- store write precedes the re-read), type key absent.
+  bindResponse (patchScimUser OwnDomain tok userId (scimPatchOp "Remove" typePath Nothing)) $ \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+
+  -- Strict round-trip consequence: the value-path filter
+  -- @emails[type eq \"work\"]@ no longer matches the stored entry, so the
+  -- Add takes the create-on-absent path, appends a second entry, and the
+  -- reduction back to one email (primary, else first) keeps the OLD address.
+  newEmail <- randomEmail
+  bindResponse (patchScimUser OwnDomain tok userId (scimAddPatchOp "emails[type eq \"work\"].value" newEmail)) $ \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+  getScimUser OwnDomain tok userId `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [wantKept]
+
+  -- PATCH Replace with null is equivalent to Remove (RFC 7643 §2.5).  Fresh
+  -- user: a type-less entry no longer matches the filter above.
+  extId2 <- randomExternalId
+  email2 <- randomEmail
+  scimUser2 <- randomScimUserWithEmailAndMeta extId2 email2 (Just "work") (Just True)
+  userId2 <- createScimUser OwnDomain tok scimUser2 >>= getJSON 201 >>= (%. "id") >>= asString
+  bindResponse (patchScimUser OwnDomain tok userId2 (scimPatchOp "Replace" typePath (Just A.Null))) $ \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [object ["value" .= email2, "primary" .= True]]
+  getScimUser OwnDomain tok userId2 `bindResponse` \res -> do
+    res.status `shouldMatchInt` 200
+    res.json %. "emails" `shouldMatch` [object ["value" .= email2, "primary" .= True]]
+
+testSparRejectsMultiplePrimaryEmails :: (HasCallStack) => App ()
+testSparRejectsMultiplePrimaryEmails = do
+  (owner, _tid, _) <- createTeam OwnDomain 1
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  email1 <- randomEmail
+  email2 <- randomEmail
+  scimUser <-
+    randomScimUserWith def
+      >>= setField
+        "emails"
+        ( toJSON
+            [ object ["value" .= email1, "primary" .= True],
+              object ["value" .= email2, "primary" .= True]
+            ]
+        )
+  bindResponse (createScimUser OwnDomain tok scimUser) $ \res ->
+    res.status `shouldMatchInt` 400
+
 testSparExternalIdDifferentFromEmail :: (HasCallStack) => App ()
 testSparExternalIdDifferentFromEmail = do
   (owner, tid, _) <- createTeam OwnDomain 1
@@ -306,6 +632,18 @@ testSparExternalIdUpdateToANonEmail = do
   updatedScimUser <- setField "externalId" extId scimUser
   updateScimUser OwnDomain tok userId updatedScimUser >>= assertStatus 400
 
+-- | A SCIM PatchOp with the given op, path, and optional value.
+scimPatchOp :: String -> String -> Maybe A.Value -> A.Value
+scimPatchOp op path value =
+  object
+    [ "schemas" .= ["urn:ietf:params:scim:api:messages:2.0:PatchOp" :: String],
+      "Operations"
+        .= [object (["op" .= op, "path" .= path] <> ["value" .= v | Just v <- [value]])]
+    ]
+
+scimAddPatchOp :: String -> String -> A.Value
+scimAddPatchOp path value = scimPatchOp "Add" path (Just (A.String . cs $ value))
+
 testSparMigrateFromExternalIdOnlyToEmail :: (HasCallStack) => Tagged "mailUnchanged" Bool -> App ()
 testSparMigrateFromExternalIdOnlyToEmail (MkTagged emailUnchanged) = do
   (owner, tid, _) <- createTeam OwnDomain 1
@@ -317,11 +655,11 @@ testSparMigrateFromExternalIdOnlyToEmail (MkTagged emailUnchanged) = do
 
   -- Verify that updating a user with an empty emails does not change the email
   bindResponse (updateScimUser OwnDomain tok userId scimUser) $ \resp -> do
-    resp.json %. "emails" `shouldMatch` (toJSON [object ["value" .= email]])
+    resp.json %. "emails" `shouldMatch` (toJSON [scimStoredEmail email])
     resp.status `shouldMatchInt` 200
 
   newEmail <- if emailUnchanged then pure email else randomEmail
-  let newEmails = (toJSON [object ["value" .= newEmail]])
+  let newEmails = toJSON [scimStoredEmail newEmail]
   updatedScimUser <- setField "emails" newEmails scimUser
   updateScimUser OwnDomain tok userId updatedScimUser `bindResponse` \resp -> do
     resp.status `shouldMatchInt` 200
@@ -353,6 +691,13 @@ checkSparGetUserAndFindByExtId domain tok extId uid k = do
 
   userByUid `shouldMatch` userByIdExtId
 
+-- | Expected SCIM email object. spar echoes the @type@/@primary@ metadata of the
+-- stored email entry exactly as provisioned (see
+-- 'Spar.Scim.User.synthesizeScimUser'); fixtures in this module send no
+-- metadata, so the expected email object has only @value@.
+scimStoredEmail :: String -> Value
+scimStoredEmail addr = object ["value" .= addr]
+
 testSparScimTokenLimit :: (HasCallStack) => App ()
 testSparScimTokenLimit = withModifiedBackend
   def
@@ -467,16 +812,23 @@ testSparScimCreateGetSearchUserGroup = do
   -- Test getting a single SCIM group by id
   gid <- respGroup1.json %. "id" & asString
   gottenGroup1 <- getScimUserGroup OwnDomain tok gid
-  respGroup1.json `shouldMatch` gottenGroup1.json
+  do
+    expected <- normalizeValue respGroup1.json
+    actual <- normalizeValue gottenGroup1.json
+    expected `shouldMatch` actual
 
   -- Test filter (get in bulk) SCIM groups
   -- 1. Match "group", results in finding all three groups created above.
-  filterScimUserGroup OwnDomain tok (Just "displayName co \"group\"") `bindResponse` \allThreeResp ->
-    (allThreeResp.json %. "Resources" & asList) `shouldMatchSet` [createdGroup1, createdGroup2, createdGroup3]
+  filterScimUserGroup OwnDomain tok (Just "displayName co \"group\"") `bindResponse` \allThreeResp -> do
+    resources <- (allThreeResp.json %. "Resources" & asList) >>= mapM normalizeValue
+    expected <- mapM normalizeValue [createdGroup1, createdGroup2, createdGroup3]
+    resources `shouldMatchSet` expected
 
   -- 2. Match "another group", results in finding "another group" and "yet another group".
-  filterScimUserGroup OwnDomain tok (Just "displayName co \"another group\"") `bindResponse` \justTwo ->
-    (justTwo.json %. "Resources" & asList) `shouldMatchSet` [createdGroup2, createdGroup3]
+  filterScimUserGroup OwnDomain tok (Just "displayName co \"another group\"") `bindResponse` \justTwo -> do
+    resources <- (justTwo.json %. "Resources" & asList) >>= mapM normalizeValue
+    expected <- mapM normalizeValue [createdGroup2, createdGroup3]
+    resources `shouldMatchSet` expected
 
   -- 3. Empty groups should have empty member list.
   respGroup4 <- createScimUserGroup OwnDomain tok $ mkScimGroup "empty group" []
@@ -623,6 +975,20 @@ testSparScimUpdateUserGroup = do
       memberValues <- (resp.json %. "members") >>= asListOf (\m -> m %. "value" >>= asString)
       memberValues `shouldMatchSet` [charlieId, dianaId]
 
+-- | Normalize on `Value` level
+--
+-- Recursively sorts all JSON arrays. This produces a canonical form, where the
+-- initial order of elements doesn't matter. Only use this function when that's
+-- desired (i.e. there is no value in the order of elements).
+normalizeValue :: (MakesValue a) => a -> App A.Value
+normalizeValue a = normalizeValueArrays <$> make a
+  where
+    normalizeValueArrays :: A.Value -> A.Value
+    normalizeValueArrays (A.Object o) = A.Object (normalizeValueArrays <$> o)
+    normalizeValueArrays (A.Array arr) =
+      A.Array . V.fromList . sort $ V.toList (normalizeValueArrays <$> arr)
+    normalizeValueArrays v = v
+
 testSparScimUpdateUserGroupRejectsInvalidMembers :: (HasCallStack) => App ()
 testSparScimUpdateUserGroupRejectsInvalidMembers = do
   (alice, tid1, []) <- createTeam OwnDomain 1
@@ -1017,7 +1383,7 @@ testScimUpdateEmailAddress (TaggedBool extIdIsEmail) (TaggedBool requireExternal
     res.json %. "id" `shouldMatch` uid
     lookupField res.json "emails"
       `shouldMatch` ( if extIdIsEmail
-                        then Just [object ["value" .= oldEmail]]
+                        then Just [scimStoredEmail oldEmail]
                         else Nothing
                     )
 
@@ -1036,11 +1402,11 @@ testScimUpdateEmailAddress (TaggedBool extIdIsEmail) (TaggedBool requireExternal
 
   updateScimUser OwnDomain tok uid newScimUser `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail]
 
   getScimUser OwnDomain tok uid `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail]
 
   when requireExternalEmailVerification $ do
     getUsersId OwnDomain [uid] `bindResponse` \res -> do
@@ -1104,7 +1470,7 @@ testScimUpdateEmailAddressAndExternalId = do
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "id" `shouldMatch` brigUserId
-    res.json %. "emails" `shouldMatch` [object ["value" .= extId1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail extId1]
 
   findUsersByExternalId OwnDomain tok extId1 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1127,11 +1493,11 @@ testScimUpdateEmailAddressAndExternalId = do
   updateScimUser OwnDomain tok brigUserId newScimUser1 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "externalId" `shouldMatch` extId1
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   findUsersByExternalId OwnDomain tok extId1 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1160,11 +1526,11 @@ testScimUpdateEmailAddressAndExternalId = do
   updateScimUser OwnDomain tok brigUserId newScimUser2 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "externalId" `shouldMatch` newExtId2
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   findUsersByExternalId OwnDomain tok newExtId2 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1193,11 +1559,11 @@ testScimUpdateEmailAddressAndExternalId = do
   updateScimUser OwnDomain tok brigUserId newScimUser3 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
     res.json %. "externalId" `shouldMatch` newEmail3
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   getScimUser OwnDomain tok brigUserId `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
-    res.json %. "emails" `shouldMatch` [object ["value" .= newEmail1]]
+    res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail1]
 
   findUsersByExternalId OwnDomain tok newEmail3 `bindResponse` \res -> do
     res.status `shouldMatchInt` 200
@@ -1409,7 +1775,7 @@ testAllowUpdatesBySCIMWhenE2EIdEnabled (TaggedBool ssoEnabled) = do
       su <- setField "emails" [object ["value" .= newEmail]] scimUser
       bindResponse (updateScimUser OwnDomain tok uid su) $ \res -> do
         res.status `shouldMatchInt` 200
-        res.json %. "emails" `shouldMatch` [object ["value" .= newEmail]]
+        res.json %. "emails" `shouldMatch` [scimStoredEmail newEmail]
       activateEmail OwnDomain newEmail
       bindResponse (getUsersId OwnDomain [uid]) $ \res -> do
         res.status `shouldMatchInt` 200
@@ -1513,7 +1879,7 @@ testScimUserIsNotAllowedToChangeNameOnRegistering = do
     resp.json %. "name" `shouldMatch` scimUserDisplayName
 
   let newProfilename = "Takemiya Masaki"
-  registerUserWith OwnDomain email code newProfilename `bindResponse` \resp -> do
+  registerUserWithVersioned (ExplicitVersion 18) OwnDomain email code newProfilename `bindResponse` \resp -> do
     resp.status `shouldMatchInt` 403
     resp.json %. "label" `shouldMatch` "managed-by-scim"
 
@@ -1542,3 +1908,47 @@ testScimUserChangeNameOnRegisteringIgnoredV16 = do
   registerUserWithVersioned (ExplicitVersion 16) OwnDomain email code newProfilename `bindResponse` \resp -> do
     resp.status `shouldMatchInt` 201
     resp.json %. "name" `shouldMatch` scimUserDisplayName
+
+-- | A pending email update (emailUnvalidated + activation code) must be
+-- invalidated when the user transitions to SCIM control.  Getting a Wire-managed
+-- user via the SCIM API triggers 'getUserById' -> 'synthesizeStoredUser' ->
+-- 'ManagedByScim' -> 'deletePendingEmailUpdate' in spar, which calls the brig
+-- internal endpoint that deletes both the activation code and the pending
+-- email entry.  See WPB-21744 / PR #5333.
+testSparScimInvalidatesPendingEmail :: (HasCallStack) => App ()
+testSparScimInvalidatesPendingEmail = do
+  -- 1. Create a team with an owner and one Wire-managed member.
+  (owner, _tid, [mem]) <- createTeam OwnDomain 2
+  memberId <- mem %. "id" >>= asString
+  memberEmail <- mem %. "email" >>= asString
+
+  -- 2. Login as the member and initiate an email update.  This creates a
+  --    pending emailUnvalidated entry plus an activation code for the new email.
+  (cookie, token) <-
+    login mem memberEmail defPassword `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      tok <- resp.json %. "access_token" & asString
+      let c = fromJust $ getCookie "zuid" resp
+      pure ("zuid=" <> c, tok)
+  newEmail <- randomEmail
+  updateEmail mem newEmail cookie token >>= assertSuccess
+
+  -- 3. Verify the activation code exists for the pending (unvalidated) email.
+  getActivationCode OwnDomain newEmail >>= assertStatus 200
+
+  -- 4. Transition the member to SCIM control.  Reading the user through the
+  --    SCIM API triggers getUserById -> synthesizeStoredUser -> (since the user
+  --    is email-only and not yet SCIM-managed) setManagedBy ManagedByScim and
+  --    deletePendingEmailUpdate.
+  tok <- createScimTokenV6 owner def >>= getJSON 200 >>= (%. "token") >>= asString
+  getScimUser OwnDomain tok memberId >>= assertStatus 200
+
+  -- 5. The activation code for the pending email has been invalidated.
+  getActivationCode OwnDomain newEmail >>= assertStatus 404
+
+  -- 6. The member's email is unchanged: the SCIM transition must not promote
+  --    the unvalidated email, only drop the pending update.
+  bindResponse (getUsersId OwnDomain [memberId]) $ \resp -> do
+    resp.status `shouldMatchInt` 200
+    u <- resp.json & asList >>= assertOne
+    u %. "email" `shouldMatch` memberEmail
diff --git a/integration/test/Test/Spar/CertFingerprintAllowlist.hs b/integration/test/Test/Spar/CertFingerprintAllowlist.hs
index a3e513900ff..e6494f52930 100644
--- a/integration/test/Test/Spar/CertFingerprintAllowlist.hs
+++ b/integration/test/Test/Spar/CertFingerprintAllowlist.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Spar.CertFingerprintAllowlist where
 
 import API.GalleyInternal (setTeamFeatureStatus)
@@ -5,12 +22,11 @@ import API.Spar (createIdpWithZHostV2, updateIdp)
 import Control.Lens ((.~), (^.))
 import Data.List.NonEmpty (NonEmpty ((:|)))
 import qualified Data.List.NonEmpty as NE
-import qualified Data.Text as T
 import Data.X509 (SignedCertificate)
-import qualified Data.X509.Extended as X509E
 import qualified SAML2.WebSSO.Test.Util as SAMLTest
 import qualified SAML2.WebSSO.Types as SAMLTypes
 import SetupHelpers
+import Testlib.Certs (fingerprintHex)
 import Testlib.Prelude
 import qualified Text.XML.DSig as XMLDSig
 
@@ -137,10 +153,6 @@ bogusFingerprint = "0000000000000000000000000000000000000000"
 firstCert :: SAMLTypes.IdPMetadata -> SignedCertificate
 firstCert meta = NE.head $ meta ^. SAMLTypes.edCertAuthnResponse
 
--- | Cert's SHA-1 fingerprint in canonical @AA:BB:..@ hex form.
-fingerprintHex :: SignedCertificate -> String
-fingerprintHex = T.unpack . X509E.renderFingerprintHex . X509E.certSha1Fingerprint
-
 -- | First cert's SHA-1, canonical @AA:BB:..@ form.
 firstCertFingerprint :: SAMLTypes.IdPMetadata -> String
 firstCertFingerprint = fingerprintHex . firstCert
diff --git a/integration/test/Test/Spar/GetByEmail.hs b/integration/test/Test/Spar/GetByEmail.hs
index 155bee47f48..ec3b8562a52 100644
--- a/integration/test/Test/Spar/GetByEmail.hs
+++ b/integration/test/Test/Spar/GetByEmail.hs
@@ -23,7 +23,9 @@ import API.Spar
 import GHC.Stack
 import qualified SAML2.WebSSO.Test.Util as SAML
 import SetupHelpers
+import Testlib.Certs (fingerprintHex)
 import Testlib.Prelude
+import qualified Text.XML.DSig as XMLDSig
 
 -- | Test the /sso/get-by-email endpoint with multi-ingress setup
 testGetSsoCodeByEmailWithMultiIngress ::
@@ -35,6 +37,8 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati
   let ernieZHost = "nginz-https.ernie.example.com"
       bertZHost = "nginz-https.bert.example.com"
 
+  credsWithCertErnie@(_, _, signedCertErnie) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCertBert@(_, _, signedCertBert) <- XMLDSig.mkSignCredsWithCert Nothing 96
   withModifiedBackend
     def
       { sparCfg =
@@ -59,6 +63,9 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati
                         ]
                   ]
               )
+            >=> setField
+              "idpCertFingerprintAllowlist"
+              (fingerprintHex <$> [signedCertErnie, signedCertBert])
       }
     $ \domain -> do
       (owner, tid, _) <- createTeam domain 1
@@ -69,7 +76,7 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati
       assertSuccess =<< setTeamFeatureStatus owner tid "validateSAMLemails" status
 
       -- Create IdP for ernie domain
-      SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertErnie
       idpIdErnie <-
         createIdpWithZHostV2 owner (Just ernieZHost) idpmetaErnie `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -77,7 +84,7 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati
           resp.json %. "id" >>= asString
 
       -- Create IdP for bert domain
-      SAML.SampleIdP idpmetaBert _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaBert _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertBert
       idpIdBert <-
         createIdpWithZHostV2 owner (Just bertZHost) idpmetaBert `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -92,10 +99,10 @@ testGetSsoCodeByEmailWithMultiIngress (TaggedBool requireExternalEmailVerificati
           [] -> assertFailure "Expected at least one email"
 
       let idpTokenConfig = if isIdPScimToken then (def {idp = Just idpIdErnie}) else def
-      scimTok <- createScimToken owner idpTokenConfig
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner idpTokenConfig
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       if isIdPScimToken
         then when requireExternalEmailVerification $ do
@@ -150,10 +157,10 @@ testGetSsoCodeByEmailRegular (TaggedBool requireExternalEmailVerification) (Tagg
           [] -> assertFailure "Expected at least one email"
 
       let idpTokenConfig = if isIdPScimToken then (def {idp = Just idpId}) else def
-      scimTok <- createScimToken owner idpTokenConfig
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner idpTokenConfig
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       if isIdPScimToken
         then when requireExternalEmailVerification $ do
@@ -169,9 +176,9 @@ testGetSsoCodeByEmailRegular (TaggedBool requireExternalEmailVerification) (Tagg
         ssoCodeStr <- resp.json %. "sso_code" >>= asString
         ssoCodeStr `shouldMatch` idpId
 
--- | Test that non-SCIM users get no SSO code
-testGetSsoCodeByEmailNonScimUser :: (HasCallStack) => App ()
-testGetSsoCodeByEmailNonScimUser = do
+-- | Test that non-SSO users get no SSO code
+testGetSsoCodeByEmailNonSSOUser :: (HasCallStack) => App ()
+testGetSsoCodeByEmailNonSSOUser = do
   withModifiedBackend
     def {sparCfg = setField "enableIdPByEmailDiscovery" True}
     $ \domain -> do
@@ -186,7 +193,7 @@ testGetSsoCodeByEmailNonScimUser = do
       usr <- randomUser domain def {activate = True}
       userEmail <- usr %. "email" & asString
 
-      -- Try to get SSO code for regular (non-SCIM) user - should return 404 with null
+      -- Try to get SSO code for regular (non-SSO) user - should return 404 with null
       getSsoCodeByEmail domain userEmail `bindResponse` \resp -> do
         resp.status `shouldMatchInt` 404
         mbSsoCode <- lookupField resp.json "sso_code"
@@ -215,10 +222,10 @@ testGetSsoCodeByEmailDisabledRegular = do
           (e : _) -> e %. "value" >>= asString
           [] -> assertFailure "Expected at least one email"
 
-      scimTok <- createScimToken owner def {idp = Just idpId}
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner def {idp = Just idpId}
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       -- Activate the email so the user can be found by email
       activateEmail domain userEmail
@@ -235,6 +242,7 @@ testGetSsoCodeByEmailDisabledMultiIngress = do
   let ernieZHost = "nginz-https.ernie.example.com"
       bertZHost = "nginz-https.bert.example.com"
 
+  credsWithCertErnie@(_, _, signedCertErnie) <- XMLDSig.mkSignCredsWithCert Nothing 96
   withModifiedBackend
     def
       { sparCfg =
@@ -259,13 +267,14 @@ testGetSsoCodeByEmailDisabledMultiIngress = do
                         ]
                   ]
               )
+            >=> setField "idpCertFingerprintAllowlist" [fingerprintHex signedCertErnie]
       }
     $ \domain -> do
       (owner, tid, _) <- createTeam domain 1
       void $ setTeamFeatureStatus owner tid "sso" "enabled"
 
       -- Create IdP for ernie domain
-      SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaErnie _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertErnie
       idpIdErnie <-
         createIdpWithZHostV2 owner (Just ernieZHost) idpmetaErnie `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -279,10 +288,10 @@ testGetSsoCodeByEmailDisabledMultiIngress = do
           (e : _) -> e %. "value" >>= asString
           [] -> assertFailure "Expected at least one email"
 
-      scimTok <- createScimToken owner def {idp = Just idpIdErnie}
-      scimToken <- scimTok.json %. "token" & asString
+      scimToken <- createScimToken owner def {idp = Just idpIdErnie}
+      scimTokenStr <- scimToken.json %. "token" & asString
 
-      createScimUser domain scimToken scimUser >>= assertSuccess
+      createScimUser domain scimTokenStr scimUser >>= assertSuccess
 
       -- Activate the email so the user can be found by email
       activateEmail domain userEmail
diff --git a/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs b/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs
new file mode 100644
index 00000000000..faa4b462d65
--- /dev/null
+++ b/integration/test/Test/Spar/MultiIngressCrossIdpSso.hs
@@ -0,0 +1,548 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Test.Spar.MultiIngressCrossIdpSso where
+
+import API.BrigInternal (getUsersId)
+import API.Common (randomHandle)
+import API.Spar
+  ( CreateScimToken (..),
+    createIdpWithZHostV2,
+    createScimToken,
+    createScimUser,
+    finalizeSamlLoginWithZHost,
+    getSPMetadataWithZHost,
+    getSsoCodeByEmailWithZHost,
+    initiateSamlLoginWithZHostAndLabel,
+  )
+import Control.Lens ((.~), (^.))
+import Data.ByteString.Char8 (unpack)
+import Data.Either.Extra
+import Data.String.Conversions (cs)
+import Data.Text (pack)
+import qualified Data.UUID as UUID
+import qualified Data.X509 as X509
+import GHC.Stack
+import qualified SAML2.WebSSO as SAML
+import qualified SAML2.WebSSO.Test.MockResponse as SAML
+import SAML2.WebSSO.Test.Util
+import SetupHelpers
+import Testlib.Certs (fingerprintHex)
+import Testlib.Prelude
+import qualified Text.XML.DSig as SAML
+
+ernieDomain, bertDomain, ernieZHost, bertZHost :: String
+ernieDomain = "ernie.example.com"
+bertDomain = "bert.example.com"
+ernieZHost = "nginz-https." <> ernieDomain
+bertZHost = "nginz-https." <> bertDomain
+
+-- | Test that - in a multi-ingress scenario -  a user provisioned under one
+-- IdP can log in via another IdP, with their SSO identity migrating to the new
+-- IdP transparently.
+--
+-- Covers both SCIM-provisioned and auto-provisioned users, and verifies
+-- back-and-forth migration between IdPs.
+testCrossIdpSsoMigration :: (HasCallStack) => TaggedBool "useScim" -> App ()
+testCrossIdpSsoMigration (TaggedBool useSCIM) = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register IdP for Ernie's domain
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+
+    -- Register IdP for Bert's domain
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString
+    bertIssuer <- idpBert.json %. "metadata.issuer" >>= asString
+
+    (biboEmail, biboNameId) <- randomEmailNameId
+
+    -- Optionally create the user via SCIM (and not automatically)
+    mScimUserId <-
+      if useSCIM
+        then do
+          -- Create SCIM token associated with Ernie's IdP
+          scimToken <- createScimToken owner (def {idp = Just idpIdErnie})
+          scimTokenStr <- scimToken.json %. "token" & asString
+
+          -- Create SCIM user with the email
+          scimUser <- randomScimUserWithEmail biboEmail biboEmail
+          scimUid <- bindResponse (createScimUser domain scimTokenStr scimUser) $ \resp -> do
+            resp.status `shouldMatchInt` 201
+            resp.json %. "id" >>= asString
+
+          activateEmail domain biboEmail
+
+          pure (Just scimUid)
+        else pure Nothing
+
+    -- Bibo logs in on Ernie ingress (should succeed)
+    userIdErnie <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+        >>= maybe (error "Expected user ID from SSO login on Ernie domain") pure
+        . fst
+
+    case mScimUserId of
+      Just scimUid ->
+        -- Validate that SCIM-created user matches SSO login user
+        scimUid `shouldMatch` userIdErnie
+      Nothing ->
+        -- Non-SCIM user was auto-provisioned. Activate them.
+        activateEmail domain biboEmail
+
+    -- Verify user's SSO ID has Ernie's issuer (not Bert's)
+    getUsersId domain [userIdErnie] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+      ssoIdTenant `shouldNotMatch` bertIssuer
+
+    -- Verify sso/get-by-email returns Ernie's IdP
+    getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdErnie
+
+    -- Bibo re-logs in on Ernie (should succeed - proves SSO works on same ingress)
+    (mUserIdErnieAgain, _) <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+
+    userIdErnieAgain <- assertJust "Expected user ID from re-login on Ernie domain" mUserIdErnieAgain
+    userIdErnieAgain `shouldMatch` userIdErnie
+
+    -- Same Bibo logs in on Bert ingress with SAME email
+    -- This should SUCCEED because of cross-IdP SSO migration.
+    (mUserIdBert, _) <-
+      loginWithSamlWithZHost
+        (Just bertZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdBert, (idpMetaBert, pCredsBert))
+
+    -- Verify the same user ID is returned (cross-IdP SSO migration worked)
+    userIdBert <- assertJust "Expected user ID from cross-IdP SSO login on Bert domain" mUserIdBert
+    userIdBert `shouldMatch` userIdErnie
+
+    -- Verify user's SSO ID was migrated to Bert's issuer (not Ernie's anymore)
+    getUsersId domain [userIdErnie] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` bertIssuer
+      ssoIdTenant `shouldNotMatch` ernieIssuer
+
+    -- Verify sso/get-by-email returns Bert's IdP for Bert's ingress after migration
+    getSsoCodeByEmailWithZHost domain (Just bertZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdBert
+
+    -- Verify sso/get-by-email returns Ernie's IdP for Ernie's ingress after migration
+    getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdErnie
+
+    -- Login on Ernie again to show back-and-forth migration works
+    (mUserIdErnieFinal, _) <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+
+    userIdErnieFinal <- assertJust "Expected user ID from final login on Ernie domain" mUserIdErnieFinal
+    userIdErnieFinal `shouldMatch` userIdErnie
+
+    -- Verify user's SSO ID was migrated back to Ernie's IdP
+    getUsersId domain [userIdErnie] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+      ssoIdTenant `shouldNotMatch` bertIssuer
+
+    -- Verify sso/get-by-email returns correct IdP by ingress
+    getSsoCodeByEmailWithZHost domain (Just ernieZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdErnie
+
+    getSsoCodeByEmailWithZHost domain (Just bertZHost) biboEmail `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoCodeStr <- resp.json %. "sso_code" >>= asString
+      ssoCodeStr `shouldMatch` idpIdBert
+
+-- | Cross-IdP migration works even when the user's first SSO login is on a different IdP
+-- than the one they were SCIM-provisioned under.
+testScimUserLoginsDifferentIdP :: (HasCallStack) => App ()
+testScimUserLoginsDifferentIdP = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register IdP for Ernie's domain
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+
+    -- Register IdP for Bert's domain
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString
+    bertIssuer <- idpBert.json %. "metadata.issuer" >>= asString
+
+    (biboEmail, biboNameId) <- randomEmailNameId
+
+    -- Provision SCIM user for Ernie's IdP
+    scimToken <- createScimToken owner (def {idp = Just idpIdErnie})
+    scimTokenStr <- scimToken.json %. "token" & asString
+
+    scimUser <- randomScimUserWithEmail biboEmail biboEmail
+    biboUid <- bindResponse (createScimUser domain scimTokenStr scimUser) $ \resp -> do
+      resp.status `shouldMatchInt` 201
+      resp.json %. "id" >>= asString
+
+    activateEmail domain biboEmail
+
+    -- Verify user was created with Ernie's SSO ID
+    getUsersId domain [biboUid] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+
+    -- Bibo logs in for the FIRST time on Bert's IdP (NOT Ernie!)
+    -- This tests cross-IdP migration when user has never logged in before (only SCIM provisioned)
+    userIdBert <-
+      loginWithSamlWithZHost
+        (Just bertZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdBert, (idpMetaBert, pCredsBert))
+        >>= maybe (error "Expected user ID from cross-IdP SSO login on Bert domain") pure
+        . fst
+
+    -- Verify the same user ID is returned (cross-IdP SSO migration worked)
+    userIdBert `shouldMatch` biboUid
+
+    -- Verify user's SSO ID was migrated to Bert's issuer
+    getUsersId domain [userIdBert] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` bertIssuer
+      ssoIdTenant `shouldNotMatch` ernieIssuer
+
+    -- Login on Ernie to verify back-migration also works
+    (mUserIdErnie, _) <-
+      loginWithSamlWithZHost
+        (Just ernieZHost)
+        domain
+        True -- expect success
+        tid
+        biboNameId
+        (idpIdErnie, (idpMetaErnie, pCredsErnie))
+
+    userIdErnie <- assertJust "Expected user ID from login on Ernie domain" mUserIdErnie
+    userIdErnie `shouldMatch` biboUid
+
+    -- Verify user's SSO ID was migrated back to Ernie's issuer
+    getUsersId domain [biboUid] `bindResponse` \resp -> do
+      resp.status `shouldMatchInt` 200
+      ssoId <- resp.json %. "0.sso_id"
+      ssoIdTenant <- ssoId %. "tenant" >>= asString
+      ssoIdTenant `shouldContain` ernieIssuer
+      ssoIdTenant `shouldNotMatch` bertIssuer
+
+-- | Login fails when the authenticating IdP's issuer is not registered for the target domain.
+testIdpNotFoundError :: (HasCallStack) => App ()
+testIdpNotFoundError = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register TWO IdPs: one for Ernie domain, one for Bert domain
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+    ernieIssuer <- idpErnie.json %. "metadata.issuer" >>= asString
+
+    SampleIdP idpMetaBert _ _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    _idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+
+    (_biboEmail, biboNameId) <- randomEmailNameId
+
+    -- Initiate on ernie, then replay the response against bert's endpoint (cross-domain).
+    -- Spar rejects: ernie's issuer is not configured for bert's domain.
+    spmetaBert <- getSPMetadataWithZHost domain (Just bertZHost) tid
+    authnReqRaw <- initiateSamlLoginWithZHostAndLabel domain (Just ernieZHost) Nothing idpIdErnie
+    let spMetaDataBert = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmetaBert.body
+        -- Manipulate: redirect the ernie authn request to bert's SP issuer so the audience
+        -- restriction in the SAML response targets bert's ACS URL, not ernie's.
+        parsedAuthnReqErnie =
+          parseAuthnReqResp authnReqRaw.body
+            & SAML.rqIssuer .~ SAML.Issuer (spMetaDataBert ^. SAML.spResponseURL)
+        idpConfigErnie =
+          SAML.IdPConfig
+            (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpIdErnie)))
+            idpMetaErnie
+            ()
+    authnReqResp <-
+      runSimpleSP
+        $ SAML.mkAuthnResponseWithSubj
+          biboNameId
+          pCredsErnie
+          idpConfigErnie
+          spMetaDataBert
+          (Just parsedAuthnReqErnie)
+          True
+
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      let bdy = unpack resp.body
+      bdy `shouldContain` "wire:sso:error:"
+      bdy `shouldContain` "\"type\":\"AUTH_ERROR\""
+      bdy `shouldContain` "wire:sso:error:not-found"
+      bdy `shouldContain` "\"label\":\"forbidden\""
+      let expectedErrorMsg =
+            "Could not find IdP: IdP with issuer '"
+              <> ernieIssuer
+              <> "' for domain '"
+              <> bertZHost
+              <> "' is not configured for this team"
+      bdy `shouldContain` expectedErrorMsg
+
+-- | Test that a user of one team cannot log in using the IdP of a different team.
+--
+-- Team B's IdP must not grant access to Team A, even when the SAML response is otherwise
+-- well-formed.
+testCrossTeamIdpLoginRejected :: (HasCallStack) => App ()
+testCrossTeamIdpLoginRejected = do
+  credsA@(_, _, certA) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  credsB@(_, _, certB) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [certA, certB] $ \domain -> do
+    -- Team A with IdP A on bert domain
+    (ownerA, tidA, _) <- createTeam domain 1
+    SampleIdP idpMetaA pCredsA _ _ <- makeSampleIdPMetadataWithIssuer credsA "team-a"
+    idpA <- createIdpWithZHostV2 ownerA (Just bertZHost) idpMetaA
+    idpIdA <- asString $ idpA.json %. "id"
+
+    -- Team B with IdP B on ernie domain
+    (ownerB, _, _) <- createTeam domain 1
+    SampleIdP idpMetaB pCredsB _ _ <- makeSampleIdPMetadataWithIssuer credsB "team-b"
+    idpB <- createIdpWithZHostV2 ownerB (Just ernieZHost) idpMetaB
+    idpIdB <- asString $ idpB.json %. "id"
+
+    -- Create Bibo as a user of Team A
+    (biboEmail, biboNameId) <- randomEmailNameId
+    _ <- loginWithSamlWithZHost (Just bertZHost) domain True tidA biboNameId (idpIdA, (idpMetaA, pCredsA))
+    activateEmail domain biboEmail
+
+    -- IdP B lives on ernie and can be initiated there, but finalization fails because IdP B
+    -- belongs to Team B, not Team A.
+    authnReqRespErnie <- buildSamlAuthnResponse domain ernieZHost tidA idpIdB idpMetaB pCredsB biboNameId
+    bindResponse (finalizeSamlLoginWithZHost domain (Just ernieZHost) tidA authnReqRespErnie) $ \resp -> do
+      resp.status `shouldMatchInt` 404
+      extractSAMLErrorPageContent resp.body `shouldContain` "IdpNotFound"
+
+    -- IdP B lives on ernie, not bert: initiation on bert is rejected
+    -- immediately independent of the team (domain mismatch).
+    bindResponse (initiateSamlLoginWithZHostAndLabel domain (Just bertZHost) Nothing idpIdB) $ \resp ->
+      resp.status `shouldMatchInt` 404
+
+-- | Test that non-email NameIDs are rejected in multi-ingress mode.
+--
+-- Multi-ingress cross-IdP SSO requires email-based NameIDs to prevent ambiguities.
+testNonEmailNameIdRejectedInMultiIngress :: (HasCallStack) => App ()
+testNonEmailNameIdRejectedInMultiIngress = do
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [bertDomain] [bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    -- Register IdP
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    randomUsername <- randomHandle
+    let usernameNameId =
+          fromRight (error "could not create name id")
+            $ SAML.mkNameID (SAML.mkUNameIDUnspecified (pack randomUsername)) Nothing Nothing Nothing
+
+    authnReqResp <- buildSamlAuthnResponse domain bertZHost tid idpIdBert idpMetaBert pCredsBert usernameNameId
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      let bdy = unpack resp.body
+      bdy `shouldContain` "wire:sso:error:multi-ingress-config-error"
+      bdy `shouldContain` "Multi-ingress SSO only supports email-based NameIDs for cross-IdP migration. Username-based NameIDs are not allowed."
+
+-- | Test that SAML responses without a prior authentication request are rejected.
+--
+-- A response referencing a request Spar never stored results in a "bad InResponseTo" error.
+testUnsolicitedSamlResponseRejected :: (HasCallStack) => App ()
+testUnsolicitedSamlResponseRejected = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    SampleIdP idpMetaErnie _ _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    void $ createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+
+    SampleIdP idpMetaBert pCredsBert _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    idpBert <- createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+    idpIdBert <- asString $ idpBert.json %. "id"
+
+    (_biboEmail, biboNameId) <- randomEmailNameId
+
+    spmeta <- getSPMetadataWithZHost domain (Just bertZHost) tid
+    let spMetaData = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmeta.body
+        idpConfig = SAML.IdPConfig (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpIdBert))) idpMetaBert ()
+    -- Create a local authn request (stored in SimpleSP's in-memory store, not in Spar's database)
+    localReq <- runSimpleSP $ SAML.createAuthnRequest 300 (idpMetaBert ^. SAML.edIssuer) (idpMetaBert ^. SAML.edIssuer)
+    authnReqResp <- makeAuthnResponse biboNameId pCredsBert idpConfig spMetaData localReq
+
+    -- Spar cannot find the request (no verdict format stored), so it rejects with server error.
+    -- This is not a user flow, so we can accept any error - even 500 - here.
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 500
+      resp.json %. "label" `shouldMatch` "server-error"
+
+-- | Test that SAML responses for one ingress are rejected when submitted to a
+-- different ingress.
+--
+-- A login request on the ernie ingress must be finalized on the ernie ingress.
+-- Finalizing on the bert ingress should fail with a bad recipient error.
+testCrossIngressRequestResponseMismatch :: (HasCallStack) => App ()
+testCrossIngressRequestResponseMismatch = do
+  ernieCredsWithCert@(_, _, ernieCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+  bertCredsWithCert@(_, _, bertCert) <- liftIO $ SAML.mkSignCredsWithCert Nothing 96
+
+  withMultiIngressBackend [ernieDomain, bertDomain] [ernieCert, bertCert] $ \domain -> do
+    (owner, tid, _) <- createTeam domain 1
+
+    SampleIdP idpMetaErnie pCredsErnie _ _ <- makeSampleIdPMetadataWithIssuer ernieCredsWithCert "ernie"
+    idpErnie <- createIdpWithZHostV2 owner (Just ernieZHost) idpMetaErnie
+    idpIdErnie <- asString $ idpErnie.json %. "id"
+
+    SampleIdP idpMetaBert _ _ _ <- makeSampleIdPMetadataWithIssuer bertCredsWithCert "bert"
+    void $ createIdpWithZHostV2 owner (Just bertZHost) idpMetaBert
+
+    (_biboEmail, biboNameId) <- randomEmailNameId
+
+    -- The SAML response's Destination is ernie's ACS (Assertion Consumer Service) URL,
+    -- i.e. ernie's /sso/finalize-login endpoint. Submitting it to bert's endpoint causes
+    -- a Destination mismatch ("bad Recipient").
+    authnReqResp <- buildSamlAuthnResponse domain ernieZHost tid idpIdErnie idpMetaErnie pCredsErnie biboNameId
+
+    -- Finalize on bert ingress — Destination mismatch, bad recipient
+    bindResponse (finalizeSamlLoginWithZHost domain (Just bertZHost) tid authnReqResp) $ \resp -> do
+      resp.status `shouldMatchInt` 200
+      let bdy = unpack resp.body
+      bdy `shouldContain` "wire:sso:error:forbidden"
+      bdy `shouldContain` "bad Recipient"
+
+-- | Run a test with the standard multi-ingress backend configuration.
+-- Takes base domain names (e.g. "ernie.example.com"); the ZHost and SSO/webapp URLs
+-- are derived from each base domain.
+-- Optionally accepts IdP certificates to add to the allowlist.
+withMultiIngressBackend :: (HasCallStack) => [String] -> [X509.SignedCertificate] -> (String -> App ()) -> App ()
+withMultiIngressBackend baseDomains certs action =
+  withModifiedBackend
+    def
+      { sparCfg =
+          removeField "saml.spSsoUri"
+            >=> removeField "saml.spAppUri"
+            >=> removeField "saml.contacts"
+            >=> setField "saml.spDomainConfigs" (object (map mkDomainEntry baseDomains))
+            >=> setField "enableIdPByEmailDiscovery" True
+            >=> if null certs
+              then pure
+              else setField "idpCertFingerprintAllowlist" (map fingerprintHex certs),
+        galleyCfg = setField "settings.featureFlags.sso" "enabled-by-default"
+      }
+    action
+  where
+    mkDomainEntry base =
+      ("nginz-https." <> base)
+        .= object
+          [ "spAppUri" .= ("https://webapp." <> base :: String),
+            "spSsoUri" .= ("https://nginz-https." <> base <> "/sso" :: String),
+            "contacts" .= [object ["type" .= ("ContactTechnical" :: String)]]
+          ]
+
+-- | Initiate a SAML login and build a signed authn response for the given NameID.
+-- Use this when testing error cases that require manual control over the finalize step.
+buildSamlAuthnResponse ::
+  (HasCallStack, MakesValue domain) =>
+  domain ->
+  String ->
+  String ->
+  String ->
+  SAML.IdPMetadata ->
+  SAML.SignPrivCreds ->
+  SAML.NameID ->
+  App SAML.SignedAuthnResponse
+buildSamlAuthnResponse domain mbZHost tid idpId idpMeta pcreds nameId = do
+  spmeta <- getSPMetadataWithZHost domain (Just mbZHost) tid
+  authnreq <- initiateSamlLoginWithZHostAndLabel domain (Just mbZHost) Nothing idpId
+  let spMetaData = fromRight (error "could not decode spmetadata") $ SAML.decode $ cs spmeta.body
+      parsedAuthnReq = parseAuthnReqResp authnreq.body
+      idpConfig =
+        SAML.IdPConfig
+          (SAML.IdPId (fromMaybe (error "invalid idp id") (UUID.fromString idpId)))
+          idpMeta
+          ()
+  makeAuthnResponse nameId pcreds idpConfig spMetaData parsedAuthnReq
diff --git a/integration/test/Test/Spar/MultiIngressIdp.hs b/integration/test/Test/Spar/MultiIngressIdp.hs
index 58960b5573a..9a490e023a3 100644
--- a/integration/test/Test/Spar/MultiIngressIdp.hs
+++ b/integration/test/Test/Spar/MultiIngressIdp.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Spar.MultiIngressIdp where
 
 import API.GalleyInternal
@@ -6,7 +23,9 @@ import Control.Lens ((.~), (^.))
 import qualified SAML2.WebSSO.Test.Util as SAML
 import qualified SAML2.WebSSO.Types as SAML
 import SetupHelpers
+import Testlib.Certs (fingerprintHex)
 import Testlib.Prelude
+import qualified Text.XML.DSig as XMLDSig
 
 ernieZHost :: String
 ernieZHost = "nginz-https.ernie.example.com"
@@ -28,6 +47,7 @@ makeSpDomainConfig zhost =
 
 testMultiIngressIdpSimpleCase :: (HasCallStack) => App ()
 testMultiIngressIdpSimpleCase = do
+  credsWithCert@(_, _, signedCert) <- XMLDSig.mkSignCredsWithCert Nothing 96
   withModifiedBackend
     def
       { sparCfg =
@@ -42,13 +62,14 @@ testMultiIngressIdpSimpleCase = do
                     kermitZHost .= makeSpDomainConfig kermitZHost
                   ]
               )
+            >=> setField "idpCertFingerprintAllowlist" [fingerprintHex signedCert]
       }
     $ \domain -> do
       (owner, tid, _) <- createTeam domain 1
       void $ setTeamFeatureStatus owner tid "sso" "enabled"
 
       -- Create IdP for one domain
-      SAML.SampleIdP idpmeta _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert
       idpId <-
         createIdpWithZHostV2 owner (Just ernieZHost) idpmeta `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -76,6 +97,9 @@ testMultiIngressIdpSimpleCase = do
 -- multi-ingress domain.
 testUnconfiguredDomain :: (HasCallStack) => App ()
 testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost -> do
+  credsWithCert1@(_, _, signedCert1) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCert2@(_, _, signedCert2) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCert3@(_, _, signedCert3) <- XMLDSig.mkSignCredsWithCert Nothing 96
   withModifiedBackend
     def
       { sparCfg =
@@ -85,12 +109,13 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost
             >=> setField
               "saml.spDomainConfigs"
               (object [ernieZHost .= makeSpDomainConfig ernieZHost])
+            >=> setField "idpCertFingerprintAllowlist" (fingerprintHex <$> [signedCert1, signedCert2, signedCert3])
       }
     $ \domain -> do
       (owner, tid, _) <- createTeam domain 1
       void $ setTeamFeatureStatus owner tid "sso" "enabled"
 
-      SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert1
       idpId1 <-
         createIdpWithZHostV2 owner (Just ernieZHost) idpmeta1 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -116,7 +141,7 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost
         resp.json %. "extraInfo.domain" `shouldMatch` ernieZHost
 
       -- Create unconfigured -> no multi-ingress domain
-      SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert2
       idpId2 <-
         createIdpWithZHostV2 owner (unconfiguredZHost) idpmeta2 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -128,7 +153,7 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost
         resp.json %. "extraInfo.domain" `shouldMatch` Null
 
       -- Create a second unconfigured -> no multi-ingress domain
-      SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert3
       idpId3 <-
         createIdpWithZHostV2 owner (unconfiguredZHost) idpmeta3 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -141,6 +166,12 @@ testUnconfiguredDomain = forM_ [Nothing, Just kermitZHost] $ \unconfiguredZHost
 
 testMultiIngressAtMostOneIdPPerDomain :: (HasCallStack) => App ()
 testMultiIngressAtMostOneIdPPerDomain = do
+  credsWithCert1@(_, _, signedCert1) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCert2@(_, _, signedCert2) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCert3@(_, _, signedCert3) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCert4@(_, _, signedCert4) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCert5@(_, _, signedCert5) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCert6@(_, _, signedCert6) <- XMLDSig.mkSignCredsWithCert Nothing 96
   withModifiedBackend
     def
       { sparCfg =
@@ -155,28 +186,33 @@ testMultiIngressAtMostOneIdPPerDomain = do
                     kermitZHost .= makeSpDomainConfig kermitZHost
                   ]
               )
+            >=> setField
+              "idpCertFingerprintAllowlist"
+              ( fingerprintHex
+                  <$> [signedCert1, signedCert2, signedCert3, signedCert4, signedCert5, signedCert6]
+              )
       }
     $ \domain -> do
       (owner, tid, _) <- createTeam domain 1
       void $ setTeamFeatureStatus owner tid "sso" "enabled"
 
-      SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta1 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert1
       idpId1 <-
         createIdpWithZHostV2 owner (Just ernieZHost) idpmeta1 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
           resp.json %. "id" >>= asString
 
       -- Creating a second IdP for the same domain -> failure
-      SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta2 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert2
       _idpId2 <-
         createIdpWithZHostV2 owner (Just ernieZHost) idpmeta2 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 409
           resp.json %. "label" `shouldMatch` "idp-duplicate-domain-for-team"
 
       -- Create an IdP for one domain and update it to another that already has one -> failure
-      SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta3 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert3
       idpId3 <-
-        createIdpWithZHostV2 owner (Just bertZHost) idpmeta2 `bindResponse` \resp -> do
+        createIdpWithZHostV2 owner (Just bertZHost) idpmeta3 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
           resp.json %. "id" >>= asString
 
@@ -186,7 +222,7 @@ testMultiIngressAtMostOneIdPPerDomain = do
           resp.json %. "label" `shouldMatch` "idp-duplicate-domain-for-team"
 
       -- Create an IdP with no domain and update it to a domain that already has one -> failure
-      SAML.SampleIdP idpmeta4 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta4 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert4
       idpId4 <-
         createIdpWithZHostV2 owner Nothing idpmeta4 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -213,7 +249,7 @@ testMultiIngressAtMostOneIdPPerDomain = do
       deleteIdp owner idpId1 `bindResponse` \resp -> do
         resp.status `shouldMatchInt` 204
 
-      SAML.SampleIdP idpmeta5 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta5 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert5
       idpId5 <-
         createIdpWithZHostV2 owner (Just ernieZHost) idpmeta5 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -221,7 +257,7 @@ testMultiIngressAtMostOneIdPPerDomain = do
           resp.json %. "id" >>= asString
 
       -- After deletion of the IdP of a domain, one can be moved from another domain
-      SAML.SampleIdP idpmeta6 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmeta6 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCert6
       createIdpWithZHostV2 owner (Just bertZHost) idpmeta6 `bindResponse` \resp -> do
         resp.status `shouldMatchInt` 409
         resp.json %. "label" `shouldMatch` "idp-duplicate-domain-for-team"
@@ -316,6 +352,11 @@ testNonMultiIngressSetupsCanHaveMoreIdPsPerDomain = do
 -- practical benefit, this complexity is not justified for now.
 testMultiIngressIdPIssuerDifferentDomains :: (HasCallStack) => App ()
 testMultiIngressIdPIssuerDifferentDomains = do
+  credsWithCertV1@(_, _, signedCertV1) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCertV1_alt@(_, _, signedCertV1_alt) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCertV1_differentIssuer@(_, _, signedCertV1_differentIssuer) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCertV2@(_, _, signedCertV2) <- XMLDSig.mkSignCredsWithCert Nothing 96
+  credsWithCertV2_alt@(_, _, signedCertV2_alt) <- XMLDSig.mkSignCredsWithCert Nothing 96
   withModifiedBackend
     def
       { sparCfg =
@@ -330,6 +371,16 @@ testMultiIngressIdPIssuerDifferentDomains = do
                     kermitZHost .= makeSpDomainConfig kermitZHost
                   ]
               )
+            >=> setField
+              "idpCertFingerprintAllowlist"
+              ( fingerprintHex
+                  <$> [ signedCertV1,
+                        signedCertV1_alt,
+                        signedCertV1_differentIssuer,
+                        signedCertV2,
+                        signedCertV2_alt
+                      ]
+              )
       }
     $ \domain -> do
       -- V1 API: Issuers must be unique per backend (across all teams)
@@ -337,7 +388,7 @@ testMultiIngressIdPIssuerDifferentDomains = do
       void $ setTeamFeatureStatus owner1 tid1 "sso" "enabled"
 
       -- Create first IdP metadata for V1
-      SAML.SampleIdP idpmetaV1 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaV1 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV1
       _idpId1 <-
         createIdpWithZHostV1 owner1 (Just ernieZHost) idpmetaV1 `bindResponse` \resp -> do
           resp.status `shouldMatchInt` 201
@@ -350,7 +401,7 @@ testMultiIngressIdPIssuerDifferentDomains = do
       void $ setTeamFeatureStatus owner2 tid2 "sso" "enabled"
 
       -- Try with same domain as original -> should fail (V1 global uniqueness)
-      SAML.SampleIdP idpmetaV1_alt _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaV1_alt _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV1_alt
       let idpmetaV1_alt_sameIssuer = idpmetaV1_alt & SAML.edIssuer .~ (idpmetaV1 ^. SAML.edIssuer)
 
       createIdpWithZHostV1 owner2 (Just ernieZHost) idpmetaV1_alt_sameIssuer `bindResponse` \resp -> do
@@ -368,7 +419,7 @@ testMultiIngressIdPIssuerDifferentDomains = do
         resp.json %. "label" `shouldMatch` "idp-already-in-use"
 
       -- Counter-example: V1 IdP with different issuer -> success
-      SAML.SampleIdP idpmetaV1_differentIssuer _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaV1_differentIssuer _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV1_differentIssuer
       void
         $ createIdpWithZHostV1 owner2 (Just ernieZHost) idpmetaV1_differentIssuer
         `bindResponse` \resp -> do
@@ -380,7 +431,7 @@ testMultiIngressIdPIssuerDifferentDomains = do
       void $ setTeamFeatureStatus owner3 tid3 "sso" "enabled"
 
       -- Create V2 IdP on team 3 with new issuer
-      SAML.SampleIdP idpmetaV2 _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaV2 _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV2
 
       _idpId3 <-
         createIdpWithZHostV2 owner3 (Just ernieZHost) idpmetaV2 `bindResponse` \resp -> do
@@ -390,7 +441,7 @@ testMultiIngressIdPIssuerDifferentDomains = do
 
       -- Try to create another V2 IdP on same team with different metadata but same issuer -> failure
       -- First, try with the same domain -> hits domain constraint (409)
-      SAML.SampleIdP idpmetaV2_alt _ _ _ <- SAML.makeSampleIdPMetadata
+      SAML.SampleIdP idpmetaV2_alt _ _ _ <- SAML.makeSampleIdPMetadataWithCert credsWithCertV2_alt
       let idpmetaV2_alt_sameIssuer = idpmetaV2_alt & SAML.edIssuer .~ (idpmetaV2 ^. SAML.edIssuer)
 
       createIdpWithZHostV2 owner3 (Just ernieZHost) idpmetaV2_alt_sameIssuer `bindResponse` \resp -> do
diff --git a/integration/test/Test/Spar/MultiIngressSSO.hs b/integration/test/Test/Spar/MultiIngressSSO.hs
index 65bc8ebdc3b..0d7a0180216 100644
--- a/integration/test/Test/Spar/MultiIngressSSO.hs
+++ b/integration/test/Test/Spar/MultiIngressSSO.hs
@@ -28,7 +28,9 @@ import qualified Data.Text as T
 import qualified Data.UUID as UUID
 import GHC.Stack
 import qualified SAML2.WebSSO as SAML
+import SAML2.WebSSO.Test.Util
 import SetupHelpers
+import Testlib.Certs (fingerprintHex)
 import qualified Testlib.KleisliXML as KXML
 import Testlib.Prelude
 import qualified Text.XML as XML
@@ -47,6 +49,8 @@ testMultiIngressSSOGeneralIdp = do
       bertZHost = "nginz-https.bert.example.com"
       kermitZHost = "nginz-https.kermit.example.com"
 
+  ernieCredsWithCert@(_, _, signedCert) <- SAML.mkSignCredsWithCert Nothing 96
+
   withModifiedBackend
     def
       { sparCfg =
@@ -70,13 +74,17 @@ testMultiIngressSSOGeneralIdp = do
                         ]
                   ]
               )
+            >=> setField "idpCertFingerprintAllowlist" [fingerprintHex signedCert]
       }
     $ \domain -> do
       (owner, tid, _) <- createTeam domain 1
       void $ setTeamFeatureStatus owner tid "sso" "enabled"
 
-      (idp, _idpMeta) <- registerTestIdPWithMetaWithPrivateCreds owner
-      idpId <- asString $ idp.json %. "id"
+      SampleIdP idpmeta _pCreds _ _ <- makeSampleIdPMetadataWithCert ernieCredsWithCert
+      idpId <-
+        createIdpWithZHostV2 owner Nothing idpmeta `bindResponse` \resp -> do
+          assertStatus 201 resp
+          resp.json %. "id" >>= asString
 
       _ernieEmail <- ("ernie@" <>) <$> randomDomain
       checkSPMetadata domain ernieZHost tid
@@ -111,6 +119,8 @@ testMultiIngressSSODomainBoundIdp = do
       bertZHost = "nginz-https.bert.example.com"
       kermitZHost = "nginz-https.kermit.example.com"
 
+  ernieCredsWithCert@(_, _, ernieCert) <- SAML.mkSignCredsWithCert Nothing 96
+
   withModifiedBackend
     def
       { sparCfg =
@@ -134,13 +144,18 @@ testMultiIngressSSODomainBoundIdp = do
                         ]
                   ]
               )
+            >=> setField "idpCertFingerprintAllowlist" [fingerprintHex ernieCert]
       }
     $ \domain -> do
       (owner, tid, _) <- createTeam domain 1
       void $ setTeamFeatureStatus owner tid "sso" "enabled"
 
-      (idp, idpMeta) <- registerTestIdPWithMetaWithPrivateCredsForZHost owner (Just ernieZHost)
-      idpId <- asString $ idp.json %. "id"
+      SampleIdP ernieIdpmeta erniePrivCreds _ _ <- makeSampleIdPMetadataWithCert ernieCredsWithCert
+      idpId <-
+        createIdpWithZHostV2 owner (Just ernieZHost) ernieIdpmeta `bindResponse` \resp -> do
+          assertStatus 201 resp
+          resp.json %. "id" >>= asString
+      let idpMeta = (ernieIdpmeta, erniePrivCreds)
 
       ernieEmail <- ("ernie@" <>) <$> randomDomain
       checkSPMetadata domain ernieZHost tid
diff --git a/integration/test/Test/Swagger.hs b/integration/test/Test/Swagger.hs
index 8152f9007c8..c150b0d62f0 100644
--- a/integration/test/Test/Swagger.hs
+++ b/integration/test/Test/Swagger.hs
@@ -30,7 +30,7 @@ import Testlib.Prelude
 import UnliftIO.Temporary
 
 existingVersions :: Set Int
-existingVersions = Set.fromList [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
+existingVersions = Set.fromList [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
 
 internalApis :: Set String
 internalApis = Set.fromList ["brig", "cannon", "cargohold", "cannon", "spar"]
diff --git a/integration/test/Test/TeamCollaborators.hs b/integration/test/Test/TeamCollaborators.hs
index 7286d1224c0..cf55c3a558e 100644
--- a/integration/test/Test/TeamCollaborators.hs
+++ b/integration/test/Test/TeamCollaborators.hs
@@ -17,7 +17,6 @@
 
 module Test.TeamCollaborators where
 
-import API.Brig
 import API.Galley
 import qualified API.GalleyInternal as Internal
 import Data.Tuple.Extra
diff --git a/integration/test/Test/Teams.hs b/integration/test/Test/Teams.hs
index 30ccc7dd8fb..1553a7da096 100644
--- a/integration/test/Test/Teams.hs
+++ b/integration/test/Test/Teams.hs
@@ -21,7 +21,7 @@ module Test.Teams where
 import API.Brig
 import qualified API.BrigInternal as I
 import API.Common
-import API.Galley (deleteTeamMember, getTeam, getTeamMembers, getTeamMembersCsv, getTeamNotifications)
+import API.Galley (deleteTeamMember, getTeam, getTeamMembers, getTeamMembersCsv, getTeamNotifications, setTeamFeatureConfig)
 import API.GalleyInternal (selectTeamMembers)
 import qualified API.GalleyInternal as I
 import API.Gundeck
@@ -34,6 +34,7 @@ import qualified Data.Map as Map
 import qualified Data.Set as Set
 import Data.Time.Clock
 import Data.Time.Format
+import MLS.Util (createMLSClient, uploadNewKeyPackage)
 import Notifications
 import SetupHelpers
 import Testlib.JSON
@@ -501,6 +502,56 @@ testListUsersEmailVisibility = do
       returnedEmails <- for returnedUsers ((%. "email") >=> asString)
       returnedEmails `shouldMatchSet` memEmails
 
+testListUsersContactStatus :: (HasCallStack) => App ()
+testListUsersContactStatus = do
+  (owner, tid, proteusUser : mlsUser : _) <- createTeam OwnDomain 3
+  noClientUser <- randomUser OwnDomain def
+  addClient proteusUser def >>= assertStatus 201
+  mlsClient <- createMLSClient def mlsUser
+  void $ uploadNewKeyPackage def mlsClient
+  putUserSupportedProtocols mlsUser ["mls"] >>= assertSuccess
+  setTeamFeatureConfig
+    owner
+    tid
+    "mls"
+    ( object
+        [ "status" .= ("enabled" :: String),
+          "config"
+            .= object
+              [ "protocolToggleUsers" .= ([] :: [String]),
+                "defaultProtocol" .= ("mls" :: String),
+                "supportedProtocols" .= (["proteus", "mls"] :: [String]),
+                "allowedCipherSuites" .= ([2] :: [Int]),
+                "defaultCipherSuite" .= (2 :: Int)
+              ]
+        ]
+    )
+    >>= assertSuccess
+
+  let users = [noClientUser, proteusUser, mlsUser]
+  userIds <- for users objQidObject
+
+  listUsers owner userIds `bindResponse` \resp -> do
+    resp.status `shouldMatchInt` 200
+    profiles <- resp.json %. "found" >>= asList
+    for_ profiles $ \profile ->
+      lookupField profile "contact_status" `shouldMatch` (Nothing :: Maybe Value)
+
+  listUsersWithContactStatus owner userIds `bindResponse` \resp -> do
+    resp.status `shouldMatchInt` 200
+    profiles <- resp.json %. "found" >>= asList
+    assertContactStatus profiles noClientUser "non-contactable"
+    assertContactStatus profiles proteusUser "contactable"
+    assertContactStatus profiles mlsUser "contactable"
+  where
+    assertContactStatus :: (HasCallStack) => [Value] -> Value -> String -> App ()
+    assertContactStatus profiles user expected = do
+      uid <- user %. "id"
+      profile <- findM (fmap (== Just uid) . flip lookupField "id") profiles
+      case profile of
+        Nothing -> assertFailure "list-users did not return the requested user"
+        Just p -> p %. "contact_status.state" `shouldMatch` expected
+
 testGetTeamsInvitationInfo :: (HasCallStack) => App ()
 testGetTeamsInvitationInfo = do
   (owner, tid, _) <- createTeam OwnDomain 1
diff --git a/integration/test/Testlib/App.hs b/integration/test/Testlib/App.hs
index d44523fcc98..55e6878b865 100644
--- a/integration/test/Testlib/App.hs
+++ b/integration/test/Testlib/App.hs
@@ -23,7 +23,6 @@ import Control.Monad.Trans.Maybe (MaybeT (MaybeT), runMaybeT)
 import qualified Control.Retry as Retry
 import Data.Aeson hiding ((.=))
 import Data.Bool (bool)
-import Data.IORef
 import Data.Maybe (isJust)
 import qualified Data.Text as T
 import qualified Data.Yaml as Yaml
@@ -38,27 +37,6 @@ import Prelude
 failApp :: (HasCallStack) => String -> App a
 failApp msg = throw (AppFailure msg callStack)
 
-getPrekey :: App Value
-getPrekey = App $ do
-  pks <- asks (.prekeys)
-  (i, pk) <- liftIO $ atomicModifyIORef pks getPK
-  pure $ object ["id" .= i, "key" .= pk]
-  where
-    getPK [] = error "Out of prekeys"
-    getPK (k : ks) = (ks, k)
-
-getLastPrekey :: App Value
-getLastPrekey = App $ do
-  pks <- asks (.lastPrekeys)
-  lpk <- liftIO $ atomicModifyIORef pks getPK
-  pure $ object ["id" .= lastPrekeyId, "key" .= lpk]
-  where
-    getPK [] = error "No last prekey left"
-    getPK (k : ks) = (ks, k)
-
-    lastPrekeyId :: Int
-    lastPrekeyId = 65535
-
 readServiceConfig :: Service -> App Value
 readServiceConfig = readServiceConfig' . configName
 
diff --git a/integration/test/Testlib/Cannon.hs b/integration/test/Testlib/Cannon.hs
index 77020825d63..4e7371a154a 100644
--- a/integration/test/Testlib/Cannon.hs
+++ b/integration/test/Testlib/Cannon.hs
@@ -26,8 +26,10 @@ module Testlib.Cannon
     withWebSocket,
     withWebSockets,
     awaitNMatchesResult,
+    awaitNMatchesResultFor,
     awaitNMatches,
     awaitMatch,
+    awaitMatchFor,
     awaitAnyEvent,
     awaitAtLeastNMatchesResult,
     awaitAtLeastNMatches,
@@ -320,7 +322,18 @@ awaitNMatchesResult ::
   (Value -> App Bool) ->
   WebSocket ->
   App AwaitResult
-awaitNMatchesResult nExpected checkMatch ws = go nExpected [] []
+awaitNMatchesResult nExpected checkMatch ws = do
+  tSecs <- asks timeOutSeconds
+  awaitNMatchesResultFor tSecs nExpected checkMatch ws
+
+awaitNMatchesResultFor ::
+  (HasCallStack) =>
+  Int ->
+  Int ->
+  (Value -> App Bool) ->
+  WebSocket ->
+  App AwaitResult
+awaitNMatchesResultFor tSecs nExpected checkMatch ws = go nExpected [] []
   where
     go 0 nonMatches matches = do
       refill nonMatches
@@ -332,7 +345,6 @@ awaitNMatchesResult nExpected checkMatch ws = go nExpected [] []
             nonMatches = reverse nonMatches
           }
     go nLeft nonMatches matches = do
-      tSecs <- asks timeOutSeconds
       mEvent <- awaitAnyEvent tSecs ws
       case mEvent of
         Just event ->
@@ -487,6 +499,16 @@ awaitMatch ::
   App Value
 awaitMatch checkMatch ws = head <$> awaitNMatches 1 checkMatch ws
 
+awaitMatchFor ::
+  (HasCallStack) =>
+  Int ->
+  (Value -> App Bool) ->
+  WebSocket ->
+  App Value
+awaitMatchFor tSecs checkMatch ws = do
+  res <- awaitNMatchesResultFor tSecs 1 checkMatch ws
+  withWebSocketFailureContext ws $ head <$> assertAwaitResult res
+
 assertNoEvent ::
   (HasCallStack) =>
   Int ->
diff --git a/integration/test/Testlib/Certs.hs b/integration/test/Testlib/Certs.hs
index f0ccdcdfb1a..026c45d494f 100644
--- a/integration/test/Testlib/Certs.hs
+++ b/integration/test/Testlib/Certs.hs
@@ -27,7 +27,9 @@ import Data.Hourglass
 import Data.Hourglass.Const
 import Data.PEM (PEM (PEM), pemWriteBS)
 import Data.String.Conversions (cs)
+import qualified Data.Text as T
 import Data.X509
+import Data.X509.Extended
 import Testlib.Prelude
 
 type RSAKeyPair = (RSA.PublicKey, RSA.PrivateKey)
@@ -139,3 +141,7 @@ mkSignedCert pubKey privKey caName ownerName =
               certPubKey = PubKeyRSA pubKey,
               certExtensions = Extensions Nothing
             }
+
+-- | Cert's SHA-1 fingerprint in canonical @AA:BB:..@ hex form.
+fingerprintHex :: SignedCertificate -> String
+fingerprintHex = T.unpack . renderFingerprintHex . certSha1Fingerprint
diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs
index 1c76c47eb6b..dd37e9c8f56 100644
--- a/integration/test/Testlib/Env.hs
+++ b/integration/test/Testlib/Env.hs
@@ -42,7 +42,6 @@ import System.Exit
 import System.FilePath
 import System.IO
 import System.IO.Temp
-import Testlib.Prekeys
 import Testlib.ResourcePool
 import Testlib.Types
 import Text.Read (readMaybe)
@@ -134,13 +133,14 @@ mkGlobalEnv cfgFile = do
         gFederationV1Domain = intConfig.federationV1.originDomain,
         gFederationV2Domain = intConfig.federationV2.originDomain,
         gDynamicDomains = (.domain) <$> Map.elems intConfig.dynamicBackends,
-        gDefaultAPIVersion = 17,
+        gDefaultAPIVersion = 18,
         gManager = manager,
         gServicesCwdBase = devEnvProjectRoot <&> ( "services"),
         gBackendResourcePool = resourcePool,
         gRabbitMQConfig = intConfig.rabbitmq,
         gRabbitMQConfigV0 = intConfig.rabbitmqV0,
         gRabbitMQConfigV1 = intConfig.rabbitmqV1,
+        gRabbitMQConfigV2 = intConfig.rabbitmqV2,
         gTempDir = tempDir,
         gTimeOutSeconds = timeOutSeconds,
         gDNSMockServerConfig = intConfig.dnsMockServer,
@@ -168,14 +168,14 @@ mkEnv :: Maybe String -> GlobalEnv -> Codensity IO Env
 mkEnv currentTestName ge = do
   mls <- liftIO . newIORef =<< mkMLSState
   liftIO $ do
-    pks <- newIORef (zip [1 ..] somePrekeys)
-    lpks <- newIORef someLastPrekeys
     curlTrace <- newIORef []
+    reqId <- newIORef 0
     pure
       Env
         { serviceMap = gServiceMap ge,
           domain1 = gDomain1 ge,
           domain2 = gDomain2 ge,
+          requestIdCounter = reqId,
           integrationTestHostName = gIntegrationTestHostName ge,
           federationV0Domain = gFederationV0Domain ge,
           federationV1Domain = gFederationV1Domain ge,
@@ -193,8 +193,6 @@ mkEnv currentTestName ge = do
               ],
           manager = gManager ge,
           servicesCwdBase = gServicesCwdBase ge,
-          prekeys = pks,
-          lastPrekeys = lpks,
           mls = mls,
           resourcePool = ge.gBackendResourcePool,
           rabbitMQConfig = ge.gRabbitMQConfig,
diff --git a/integration/test/Testlib/HTTP.hs b/integration/test/Testlib/HTTP.hs
index 643ff624f12..ad48ed444d4 100644
--- a/integration/test/Testlib/HTTP.hs
+++ b/integration/test/Testlib/HTTP.hs
@@ -231,9 +231,20 @@ zType = addHeader "Z-Type"
 zHost :: String -> HTTP.Request -> HTTP.Request
 zHost = addHeader "Z-Host"
 
+newRequestId :: App Int
+newRequestId = do
+  counter <- asks (.requestIdCounter)
+  liftIO . atomicModifyIORef counter $ \x -> (x + 1, x + 1)
+
 submit :: String -> HTTP.Request -> App Response
 submit method req0 = do
-  let request = req0 {HTTP.method = T.encodeUtf8 (T.pack method)}
+  reqIdNum <- newRequestId
+  testName <- asks (fromMaybe "not_test" . (.currentTestName))
+  let reqId = testName <> "__" <> method <> "_" <> cs (HTTP.path req0) <> "__" <> show reqIdNum
+  let request =
+        (req0 & addHeader "Request-Id" reqId)
+          { HTTP.method = T.encodeUtf8 (T.pack method)
+          }
   manager <- asks (.manager)
   response <- liftIO $ HTTP.httpLbs request manager
   let json = Aeson.decode (HTTP.responseBody response)
diff --git a/integration/test/Testlib/MockIntegrationService.hs b/integration/test/Testlib/MockIntegrationService.hs
index 7962fb4052f..f632ad93d72 100644
--- a/integration/test/Testlib/MockIntegrationService.hs
+++ b/integration/test/Testlib/MockIntegrationService.hs
@@ -45,6 +45,7 @@ import Network.Wai as Wai
 import qualified Network.Wai.Handler.Warp as Warp
 import qualified Network.Wai.Handler.Warp.Internal as Warp
 import qualified Network.Wai.Handler.WarpTLS as Warp
+import Testlib.Prekeys
 import Testlib.Prelude hiding (IntegrationConfig (integrationTestHostName))
 import UnliftIO (MonadUnliftIO (withRunInIO))
 import UnliftIO.Async
@@ -127,7 +128,7 @@ instance (App ~ f) => Default (CreateMock f) where
   def =
     MkCreateMock
       { nextLastPrey = getLastPrekey,
-        somePrekeys = replicateM 3 getPrekey
+        somePrekeys = getPrekeys 3
       }
 
 data LhApiVersion = V0 | V1
diff --git a/integration/test/Testlib/ModService.hs b/integration/test/Testlib/ModService.hs
index 0c4f7d0cc35..a02f921639a 100644
--- a/integration/test/Testlib/ModService.hs
+++ b/integration/test/Testlib/ModService.hs
@@ -516,9 +516,22 @@ checkServiceIsUp :: String -> Service -> App Bool
 checkServiceIsUp _ Nginz = pure True
 checkServiceIsUp domain srv = do
   req <- baseRequest domain srv Unversioned "/i/status"
+  mExtReq <- case srv of
+    FederatorInternal -> do
+      sMap <- getServiceMap domain
+      let extHostPort = sMap.federatorExternal
+          extUrl = "http://" <> extHostPort.host <> ":" <> show extHostPort.port <> "/i/status"
+      Just <$> externalRequest extUrl
+    _ -> pure Nothing
   checkStatus <- appToIO $ do
     res <- submit "GET" req
-    pure (res.status `elem` [200, 204])
+    if res.status `elem` [200, 204]
+      then case mExtReq of
+        Just extReq -> do
+          extRes <- submit "GET" extReq
+          pure (extRes.status `elem` [200, 204])
+        Nothing -> pure True
+      else pure False
   eith <- liftIO (E.try checkStatus)
   pure $ either (\(_e :: HTTP.HttpException) -> False) id eith
 
diff --git a/integration/test/Testlib/Prekeys.hs b/integration/test/Testlib/Prekeys.hs
index 61912da701f..a37331ef19e 100644
--- a/integration/test/Testlib/Prekeys.hs
+++ b/integration/test/Testlib/Prekeys.hs
@@ -16,83 +16,53 @@
 -- with this program. If not, see .
 
 module Testlib.Prekeys
-  ( somePrekeys,
-    someLastPrekeys,
-    somePrekeysRendered,
-    someLastPrekeysRendered,
+  ( getPrekey,
+    getPrekeys,
+    getLastPrekey,
   )
 where
 
+import qualified Codec.CBOR.Encoding as CBOR
+import qualified Codec.CBOR.Write as CBOR
+import qualified Crypto.PubKey.Ed25519 as Ed25519
+import qualified Crypto.Random as Crypto
 import Data.Aeson
+import qualified Data.ByteArray as ByteArray
+import qualified Data.ByteString.Base64 as Base64
 import Data.String
+import Data.String.Conversions (cs)
 import Data.Word
 import Prelude
 
--- | FUTUREWORK: client ids are calculated from prekeys in brig, so we should have a more
--- robust mechanism to pick them, to avoid id clashes as well as running out of list.  just
--- call cryptobox?  (or fake it, find out where the id is encoded in the key payload, count
--- that inside an MVar, and return the same key with different id every time?)
-somePrekeys :: [String]
-somePrekeys =
-  [ "pQABAQECoQBYIOjl7hw0D8YRNqkkBQETCxyr7/ywE/2R5RWcUPM+GJACA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQICoQBYIGoXawUQWQ9ZW+MXhvuo9ALOBUjLff8S5VdAokN29C1OA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQMCoQBYIEjdt+YWd3lHmG8pamULLMubAMZw556IO8kW7s1MLFytA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQQCoQBYIPIaOA3Xqfk4Lh2/pU88Owd2eW5eplHpywr+Mx4QGyiMA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQUCoQBYIHnafNR4Gh3ID71lYzToewEVag4EKskDFq+gaeraOlSJA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQYCoQBYIFXUkVftE7kK22waAzhOjOmJVex3EBTU8RHZFx2o1Ed8A6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQcCoQBYIDXdN8VlKb5lbgPmoDPLPyqNIEyShG4oT/DlW0peRRZUA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQgCoQBYIJH1ewvIVV3yGqQvdr/QM9HARzMgo5ksOTRyKEuN2aZzA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQkCoQBYIFcAnXdx0M1Q1hoDDfgMK9r+Zchn8YlVHHaQwQYhRk1dA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQoCoQBYIGs3vyxwmzEZ+qKNy4wpFkxc+Bgkb0D76ZEbxeeh/9DVA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQsCoQBYIGUiBeOJALP5dkMduUZ/u6MDhHNrsrBUa3f0YlSSWZbzA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQwCoQBYIMp6QNNTPDZgL3DSSD/QWWnBI7LsTZp2RhY/HLqnIwRZA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQ0CoQBYIJXSSUrE5RCNyB5pg+m6vGwK7RvJ+rs9dsdHitxnfDhuA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQ4CoQBYIHmtOX7jCKBHFDysb4H0z/QWoCSaEyjerZaT/HOP8bgDA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABAQ8CoQBYIIaMCTcPKj2HuYQ7i9ZaxUw9j5Bz8TPjoAaTZ5eB0w1kA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABARACoQBYIHWAOacKuWH81moJVveJ0FSfipWocfspOIBhaU6VLWUsA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABARECoQBYIA8XtUXtnMxQslULnNAeHBIivlLRe/+qdh2j6nTfDAchA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABARICoQBYIGgzg6SzgTTOgnk48pa6y2Rgjy004DkeBo4CMld3Jlr6A6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABARMCoQBYIEoEFiIpCHgn74CAD+GhIfIgbQtdCqQqkOXHWxRlG6Y6A6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABARQCoQBYINVEwTRxNSe0rxZxon4Rifz2l4rtQZn7mHtKYCiFAK9IA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABARUCoQBYIN3aeX2Ayi2rPFbiaYb+O2rdHUpFhzRs2j28pCmbGpflA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY=",
-    "pQABARYCoQBYIJe5OJ17YKQrNmIH3sE++r++4Z5ld36axqAMjjQ3jtQWA6EAoQBYILLf1TIwSB62q69Ojs/X1tzJ+dYHNAw4QbW/7TC5vSZqBPY="
-  ]
+getPrekey :: (Crypto.MonadRandom m) => m Value
+getPrekey = mkPrekey 1
 
-someLastPrekeys :: [String]
-someLastPrekeys =
-  [ "pQABARn//wKhAFggnCcZIK1pbtlJf4wRQ44h4w7/sfSgj5oWXMQaUGYAJ/sDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggwO2any+CjiGP8XFYrY67zHPvLgp+ysY5k7vci57aaLwDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggoChErA5oTI5JT769hJV+VINmU8kougGdYqGd2U7hPa8DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggPLk4BBJ8THVLGm7r0K7EJITRlJnt6bpNzM9GTNRYcCcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggqHASsRlZ1i8dESXRXBL2OvR+0yGUtqK9vJfzol1E+osDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggx/N1YhKXSJYJQxhWgHSA4ASaJKIHDJfmEnojfnp9VQ8DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggVL6QIpoqmtKxmB8HToiAPxfjSDEzJEUAoFKfhXou06YDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggRs74/ViOrHN+aS2RbGCwC0sJv1Sp/Q0pmRB15s9DCBMDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggtNO/hrwzt9M/1X6eK2sG6YFmA7BDqlFMEipbZOsg0vcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFgg1rZEY6vbAnEz+Ern5kRny/uKiIrXTb/usQxGnceV2HADoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFgg2647mOAVeOdhW57Q1zXDigDxRz/hB8ITFSZ7uo+pXH4DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggjddbHizABYOY0T6rvJeZCvV20dvTT9BYv95ri9bqSb8DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggCKT/GspZquUY6vKC4TFvaFqTH1QGG1ptauiaulnfqkUDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggv7bf/kEsTKFDGSgswsywq6AIxBq5AqZbLjDYDHfGjrcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggUbjGhhh8EwZEPSz+Y31rYNUu7jsRR8dy1F5FSiJXfXEDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFgg/4nz1uHiPBVGFvYjTMwGQ31bSFNctbU0r2nBtpsK9kcDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggwbJDyKl7T3+3Ihc0YF06Dz2J11My5qn7JKG+U+ti8lQDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFgglc6nCoZR2/qjLp0tr7vRyuXqb7ugdHHDadjX7zSl4uMDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFgg5ER8h0/bIADXjBXe/XPKdzekgv6nhJ4hp3vJ3jtTSbUDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggsgV6jq+GuNuvXk+ctHh570cNqEmfPhz34wcYCMCf9xIDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggdQdlPqkBw6+phKhohp3YaWQL710euZDnyMLFwf2cS0oDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggKlsI/snuQMoYcZRw/kN+BobPV5gwYeBClp0Wx9btTGUDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggtruFBClEgdPKvjpHsYLlWMev9L4OmYZwlxbY0NwvzOwDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggRUdh4cuYtFNL46RLnPy65goYInyreStKwsEcY3pPlLkDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggQtT7lLZzH171F4jCbHNwxEAt28FwdQ8Kt2tbxFzPgC0DoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g==",
-    "pQABARn//wKhAFggQeUPM119c+6zRsEupA8zshTfrZiLpXx1Ji0UMMumq9IDoQChAFgglacihnqg/YQJHkuHNFU7QD6Pb3KN4FnubaCF2EVOgRkE9g=="
-  ]
+getPrekeys :: (Crypto.MonadRandom m) => Word16 -> m [Value]
+getPrekeys n = mapM mkPrekey [0 .. n]
 
-render :: [Word16] -> [String] -> [Value]
-render is = zipWith (\i k -> object [fromString "id" .= i, fromString "key" .= k]) is
+getLastPrekey :: (Crypto.MonadRandom m) => m Value
+getLastPrekey = mkPrekey maxBound
 
-somePrekeysRendered :: [Value]
-somePrekeysRendered = render [1 ..] somePrekeys
+mkPrekey :: (Crypto.MonadRandom m) => Word16 -> m Value
+mkPrekey prekeyId = do
+  pk <- newPrekey prekeyId
+  pure $ object [fromString "id" .= prekeyId, fromString "key" .= pk]
 
-someLastPrekeysRendered :: [Value]
-someLastPrekeysRendered = render (repeat maxBound) someLastPrekeys
+-- | https://github.com/wireapp/proteus/blob/bb759d762bfde376fa5a8a08b1d1153a345ab28a/src/internal/keys.rs#L305
+newPrekey :: (Crypto.MonadRandom m) => Word16 -> m String
+newPrekey prekeyId = do
+  secretKey <- Ed25519.generateSecretKey
+  let publicKey = Ed25519.toPublic secretKey
+  identitySecretKey <- Ed25519.generateSecretKey
+  let identityPublicKey = Ed25519.toPublic identitySecretKey
+      encodePublicKey k = CBOR.encodeMapLen 1 <> CBOR.encodeWord8 0 <> CBOR.encodeBytes (ByteArray.convert k)
+      encodedIdentityKey = CBOR.encodeMapLen 1 <> CBOR.encodeWord8 0 <> encodePublicKey identityPublicKey
+      cbor =
+        CBOR.toStrictByteString $
+          CBOR.encodeMapLen 5
+            <> (CBOR.encodeWord8 0 <> CBOR.encodeWord8 1)
+            <> (CBOR.encodeWord8 1 <> CBOR.encodeWord16 prekeyId)
+            <> (CBOR.encodeWord8 2 <> encodePublicKey publicKey)
+            <> (CBOR.encodeWord8 3 <> encodedIdentityKey)
+            <> (CBOR.encodeWord8 4 <> CBOR.encodeNull)
+  pure . cs $ Base64.encode cbor
diff --git a/integration/test/Testlib/Run.hs b/integration/test/Testlib/Run.hs
index 5c0cd14540b..e122e2c4dd9 100644
--- a/integration/test/Testlib/Run.hs
+++ b/integration/test/Testlib/Run.hs
@@ -177,7 +177,7 @@ runTests tests mXMLOutput cfg = do
             pure (TestSuiteReport [TestCaseReport qname TestSuccess tm])
       writeChan output Nothing
       wait displayThread
-      deleteFederationV0AndV1Queues genv
+      deleteFederationVQueues genv
       printReport report
       mapM_ (saveXMLReport report) mXMLOutput
       when (any (\testCase -> testCase.result /= TestSuccess) report.cases) $
@@ -216,8 +216,8 @@ runMigrations = do
       (_, _, _, ph) <- liftIO $ createProcess cp
       void $ liftIO $ waitForProcess ph
 
-deleteFederationV0AndV1Queues :: GlobalEnv -> IO ()
-deleteFederationV0AndV1Queues env = do
+deleteFederationVQueues :: GlobalEnv -> IO ()
+deleteFederationVQueues env = do
   let testDomains = env.gDomain1 : env.gDomain2 : env.gDynamicDomains
   putStrLn "Attempting to delete federation V0 queues..."
   (mV0User, mV0Pass) <- readCredsFromEnvWithSuffix "V0"
@@ -228,6 +228,11 @@ deleteFederationV0AndV1Queues env = do
   (mV1User, mV1Pass) <- readCredsFromEnvWithSuffix "V1"
   fromMaybe (putStrLn "No or incomplete credentials for fed V1 RabbitMQ") $
     deleteFederationQueues testDomains env.gRabbitMQConfigV1 <$> mV1User <*> mV1Pass
+
+  putStrLn "Attempting to delete federation V2 queues..."
+  (mV2User, mV2Pass) <- readCredsFromEnvWithSuffix "V2"
+  fromMaybe (putStrLn "No or incomplete credentials for fed V2 RabbitMQ") $
+    deleteFederationQueues testDomains env.gRabbitMQConfigV2 <$> mV2User <*> mV2Pass
   where
     readCredsFromEnvWithSuffix :: String -> IO (Maybe Text, Maybe Text)
     readCredsFromEnvWithSuffix suffix =
diff --git a/integration/test/Testlib/Types.hs b/integration/test/Testlib/Types.hs
index 5b9de9696f4..638c6a65279 100644
--- a/integration/test/Testlib/Types.hs
+++ b/integration/test/Testlib/Types.hs
@@ -141,6 +141,7 @@ data GlobalEnv = GlobalEnv
     gRabbitMQConfig :: RabbitMqAdminOpts,
     gRabbitMQConfigV0 :: RabbitMqAdminOpts,
     gRabbitMQConfigV1 :: RabbitMqAdminOpts,
+    gRabbitMQConfigV2 :: RabbitMqAdminOpts,
     gTempDir :: FilePath,
     gTimeOutSeconds :: Int,
     gDNSMockServerConfig :: DNSMockServerConfig,
@@ -160,6 +161,7 @@ data IntegrationConfig = IntegrationConfig
     rabbitmq :: RabbitMqAdminOpts,
     rabbitmqV0 :: RabbitMqAdminOpts,
     rabbitmqV1 :: RabbitMqAdminOpts,
+    rabbitmqV2 :: RabbitMqAdminOpts,
     cassandra :: CassandraConfig,
     dnsMockServer :: DNSMockServerConfig,
     cellsEventQueue :: String
@@ -180,6 +182,7 @@ instance FromJSON IntegrationConfig where
         <*> o .: fromString "rabbitmq"
         <*> o .: fromString "rabbitmq-v0"
         <*> o .: fromString "rabbitmq-v1"
+        <*> o .: fromString "rabbitmq-v2"
         <*> o .: fromString "cassandra"
         <*> o .: fromString "dnsMockServer"
         <*> o .: fromString "cellsEventQueue"
@@ -252,6 +255,7 @@ stopQueueWatcher watcher = void $ tryPutMVar watcher.doneVar ()
 -- | Initialised once per test.
 data Env = Env
   { serviceMap :: Map String ServiceMap,
+    requestIdCounter :: IORef Int,
     domain1 :: String,
     domain2 :: String,
     integrationTestHostName :: String,
@@ -263,8 +267,6 @@ data Env = Env
     apiVersionByDomain :: Map String Int,
     manager :: HTTP.Manager,
     servicesCwdBase :: Maybe FilePath,
-    prekeys :: IORef [(Int, String)],
-    lastPrekeys :: IORef [String],
     mls :: IORef MLSState,
     resourcePool :: ResourcePool BackendResource,
     rabbitMQConfig :: RabbitMqAdminOpts,
diff --git a/libs/extended/default.nix b/libs/extended/default.nix
index 4dd312bbaa6..1813326fc12 100644
--- a/libs/extended/default.nix
+++ b/libs/extended/default.nix
@@ -19,7 +19,7 @@
 , errors
 , exceptions
 , hasql
-, hasql-pool
+, hasql-resource-pool
 , hspec
 , hspec-discover
 , http-client
@@ -49,7 +49,6 @@
 , transformers
 , types-common
 , unliftio
-, uuid
 , wai
 }:
 mkDerivation {
@@ -72,7 +71,7 @@ mkDerivation {
     errors
     exceptions
     hasql
-    hasql-pool
+    hasql-resource-pool
     http-client
     http-client-tls
     http-types
@@ -96,7 +95,6 @@ mkDerivation {
     transformers
     types-common
     unliftio
-    uuid
     wai
   ];
   testHaskellDepends = [
diff --git a/libs/extended/extended.cabal b/libs/extended/extended.cabal
index 0174d4b10cc..eee2d884228 100644
--- a/libs/extended/extended.cabal
+++ b/libs/extended/extended.cabal
@@ -103,7 +103,7 @@ library
     , errors
     , exceptions
     , hasql
-    , hasql-pool
+    , hasql-resource-pool
     , http-client
     , http-client-tls
     , http-types
@@ -127,7 +127,6 @@ library
     , transformers
     , types-common
     , unliftio
-    , uuid
     , wai
 
   default-language:   GHC2021
diff --git a/libs/extended/src/Data/Hourglass/Const.hs b/libs/extended/src/Data/Hourglass/Const.hs
index 0e2f9b796a5..eef02d8fe23 100644
--- a/libs/extended/src/Data/Hourglass/Const.hs
+++ b/libs/extended/src/Data/Hourglass/Const.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Data.Hourglass.Const (midnight) where
 
 import Data.Hourglass
diff --git a/libs/extended/src/Data/X509/Extended.hs b/libs/extended/src/Data/X509/Extended.hs
index 81c40888a8e..794e6123631 100644
--- a/libs/extended/src/Data/X509/Extended.hs
+++ b/libs/extended/src/Data/X509/Extended.hs
@@ -1,5 +1,22 @@
 {-# LANGUAGE RecordWildCards #-}
 
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Data.X509.Extended
   ( certToString,
     certDescription,
diff --git a/libs/extended/src/Hasql/Pool/Extended.hs b/libs/extended/src/Hasql/Pool/Extended.hs
index cf6e6d0f4f1..cebbdca0442 100644
--- a/libs/extended/src/Hasql/Pool/Extended.hs
+++ b/libs/extended/src/Hasql/Pool/Extended.hs
@@ -18,23 +18,23 @@
 module Hasql.Pool.Extended where
 
 import Data.Aeson
-import Data.Map as Map
 import Data.Misc
-import Data.Set qualified as Set
-import Data.UUID
+import Hasql.Connection qualified
 import Hasql.Connection.Settings qualified as HasqlConnSettings
-import Hasql.Pool as HasqlPool
-import Hasql.Pool.Config qualified as HasqlPool
-import Hasql.Pool.Observation
+import Hasql.Pool qualified as HasqlPool
 import Imports
 import PostgresqlConnectionString qualified
 import Prometheus
+import UnliftIO.IO (getMonotonicTime)
 import Util.Options
 
 data PoolConfig = PoolConfig
   { size :: Int,
+    -- | Configured pool acquisition wait time. hasql-resource-pool only
+    -- accepts whole seconds here, so we round up to the nearest second and
+    -- pass it through as the pool acquisition timeout.
     acquisitionTimeout :: Duration,
-    agingTimeout :: Duration,
+    -- | Controls how long idle connections stay resident in the pool.
     idlenessTimeout :: Duration
   }
   deriving (Eq, Show)
@@ -44,100 +44,127 @@ instance FromJSON PoolConfig where
     PoolConfig
       <$> o .: "size"
       <*> o .: "acquisitionTimeout"
-      <*> o .: "agingTimeout"
       <*> o .: "idlenessTimeout"
 
--- | Creates a pool from postgres config params
---
--- HasqlPool.staticConnectionSettings translates the connection settings to the pool settings
--- HasqlPool.settings translates the pool settings into pool config
--- HasqlPool.acquire creates the pool.
--- ezpz.
-initPostgresPool :: PoolConfig -> Map Text Text -> Maybe FilePathSecrets -> IO HasqlPool.Pool
-initPostgresPool config pgConfig mFpSecrets = do
-  mPw <- for mFpSecrets initCredentials
-  let pgSettings =
-        HasqlConnSettings.connectionString (PostgresqlConnectionString.toUrl $ PostgresqlConnectionString.fromKeyValueParams pgConfig)
-          <> foldMap HasqlConnSettings.password mPw
-  metrics <- initHasqlPoolMetrics
-  connsRef <- newIORef $ Connections mempty mempty mempty
-  HasqlPool.acquire $
-    HasqlPool.settings
-      [ HasqlPool.staticConnectionSettings pgSettings,
-        HasqlPool.size config.size,
-        HasqlPool.acquisitionTimeout config.acquisitionTimeout.duration,
-        HasqlPool.agingTimeout config.agingTimeout.duration,
-        HasqlPool.idlenessTimeout config.idlenessTimeout.duration,
-        HasqlPool.observationHandler (observationHandler connsRef metrics)
-      ]
-
 data HasqlPoolMetrics = HasqlPoolMetrics
   { readyForUseGauge :: Gauge,
     inUseGauge :: Gauge,
     establishedCounter :: Counter,
-    terminationCounter :: Counter,
+    connectionFailureCounter :: Counter,
+    acquisitionTimeoutCounter :: Counter,
     sessionFailureCounter :: Counter,
-    sessionCounter :: Counter
+    sessionCounter :: Counter,
+    connectionAcquisitionDuration :: Histogram,
+    sessionDuration :: Histogram
   }
 
-initHasqlPoolMetrics :: IO HasqlPoolMetrics
-initHasqlPoolMetrics = do
-  HasqlPoolMetrics
-    <$> register (gauge $ Info "wire_hasql_pool_ready_for_use" "Number of hasql pool connections ready for use")
-    <*> register (gauge $ Info "wire_hasql_pool_in_use" "Number of hasql pool connections ready for use")
-    <*> register (counter $ Info "wire_hasql_pool_connection_established_count" "Number of established connections")
-    <*> register (counter $ Info "wire_hasql_pool_connection_terminated_count" "Number of terminated connections")
-    <*> register (counter $ Info "wire_hasql_pool_session_failure_count" "Number of times a session has failed")
-    <*> register (counter $ Info "wire_hasql_pool_session_count" "Number of times a session was created")
-
-data Connections = Connections
-  { connecting :: Set UUID,
-    inUse :: Set UUID,
-    readyForUse :: Set UUID
+data Pool = Pool
+  { rawPool :: HasqlPool.Pool,
+    metrics :: HasqlPoolMetrics,
+    -- | Pool acquisition timeout in seconds, rounded up from the configured
+    -- duration. This is used by the session runner to bound waiting for an
+    -- available connection slot.
+    poolAcquisitionTimeout :: Duration
   }
 
-observationHandler :: IORef Connections -> HasqlPoolMetrics -> Observation -> IO ()
-observationHandler connsRef metrics (ConnectionObservation connId status) = do
-  case status of
-    ConnectingConnectionStatus -> do
-      modifyIORef' connsRef (\conns -> conns {connecting = Set.insert connId conns.connecting})
-    ReadyForUseConnectionStatus reason -> do
-      connsChange <- case reason of
-        SessionFailedConnectionReadyForUseReason _ -> do
-          incCounter metrics.sessionFailureCounter
-          pure $ \conns -> conns {inUse = Set.delete connId conns.inUse}
-        SessionSucceededConnectionReadyForUseReason -> do
-          pure $ \conns -> conns {inUse = Set.delete connId conns.inUse}
-        EstablishedConnectionReadyForUseReason -> do
-          incCounter metrics.establishedCounter
-          pure (\conns -> conns {connecting = Set.delete connId conns.connecting})
-
-      (inUseSize, readyForUseSize) <- atomicModifyIORef' connsRef $ \conns ->
-        let newConns = (connsChange conns) {readyForUse = Set.insert connId conns.readyForUse}
-         in (newConns, (Set.size newConns.inUse, Set.size newConns.readyForUse))
-
-      setGauge metrics.readyForUseGauge (fromIntegral readyForUseSize)
-      setGauge metrics.inUseGauge (fromIntegral inUseSize)
-    InUseConnectionStatus -> do
-      incCounter metrics.sessionCounter
-      (inUseSize, readyForUseSize) <- atomicModifyIORef' connsRef $ \conns ->
-        let newConns =
-              conns
-                { readyForUse = Set.delete connId conns.readyForUse,
-                  inUse = Set.insert connId conns.inUse
-                }
-         in (newConns, (Set.size newConns.inUse, Set.size newConns.readyForUse))
-      setGauge metrics.readyForUseGauge (fromIntegral readyForUseSize)
-      setGauge metrics.inUseGauge (fromIntegral inUseSize)
-    TerminatedConnectionStatus _ -> do
-      (inUseSize, readyForUseSize) <- atomicModifyIORef' connsRef $ \conns ->
-        let newConns =
-              conns
-                { connecting = Set.delete connId conns.connecting,
-                  readyForUse = Set.delete connId conns.readyForUse,
-                  inUse = Set.delete connId conns.inUse
-                }
-         in (newConns, (Set.size newConns.inUse, Set.size newConns.readyForUse))
-      incCounter metrics.terminationCounter
-      setGauge metrics.readyForUseGauge (fromIntegral readyForUseSize)
-      setGauge metrics.inUseGauge (fromIntegral inUseSize)
+recordHasqlPoolConnectionAcquisition :: HasqlPoolMetrics -> Double -> IO ()
+recordHasqlPoolConnectionAcquisition metrics secs =
+  observe metrics.connectionAcquisitionDuration secs
+
+recordHasqlPoolConnectionEstablished :: HasqlPoolMetrics -> IO ()
+recordHasqlPoolConnectionEstablished metrics =
+  void $ addCounter metrics.establishedCounter 1
+
+recordHasqlPoolConnectionFailure :: HasqlPoolMetrics -> IO ()
+recordHasqlPoolConnectionFailure metrics =
+  void $ addCounter metrics.connectionFailureCounter 1
+
+recordHasqlPoolSessionStarted :: HasqlPoolMetrics -> IO ()
+recordHasqlPoolSessionStarted metrics =
+  void $ addCounter metrics.sessionCounter 1
+
+recordHasqlPoolSessionFailure :: HasqlPoolMetrics -> IO ()
+recordHasqlPoolSessionFailure metrics =
+  void $ addCounter metrics.sessionFailureCounter 1
+
+recordHasqlPoolSessionDuration :: HasqlPoolMetrics -> Double -> IO ()
+recordHasqlPoolSessionDuration metrics secs =
+  observe metrics.sessionDuration secs
+
+recordHasqlPoolAcquisitionTimeout :: HasqlPoolMetrics -> IO ()
+recordHasqlPoolAcquisitionTimeout metrics =
+  void $ addCounter metrics.acquisitionTimeoutCounter 1
+
+recordHasqlPoolStats :: Pool -> IO ()
+recordHasqlPoolStats pool = do
+  -- hasql-resource-pool does not expose per-acquire/release callbacks, so
+  -- these gauges are refreshed from the pool's current total connections stats instead.
+  poolStats <- HasqlPool.stats pool.rawPool
+  setGauge pool.metrics.readyForUseGauge (fromIntegral poolStats.available)
+  setGauge pool.metrics.inUseGauge (fromIntegral poolStats.currentUsage)
+
+startHasqlPoolStatsReporter :: Pool -> IO ()
+startHasqlPoolStatsReporter pool = void $ forkIO $ forever $ do
+  recordHasqlPoolStats pool
+  threadDelay (5 * 1_000_000) -- 5s
+
+-- | Creates a pool from postgres config params.
+--
+-- 'acquisitionTimeout' is mapped to the pool acquisition timeout,
+-- 'idlenessTimeout' controls how long idle connections stay resident.
+initPostgresPool :: PoolConfig -> Map Text Text -> Maybe FilePathSecrets -> IO Pool
+initPostgresPool config pgConfig mFpSecrets = do
+  mPw <- for mFpSecrets initCredentials
+  let pgSettings =
+        HasqlConnSettings.connectionString (PostgresqlConnectionString.toUrl $ PostgresqlConnectionString.fromKeyValueParams pgConfig)
+          <> foldMap HasqlConnSettings.password mPw
+  metrics <- mkHasqlPoolMetrics
+  rawPool <-
+    HasqlPool.acquireWith
+      (instrumentedConnectionGetter metrics (Hasql.Connection.acquire pgSettings))
+      ( config.size,
+        realToFrac config.idlenessTimeout.duration,
+        unusedSettings
+      )
+  let pool = Pool {rawPool, metrics, poolAcquisitionTimeout = config.acquisitionTimeout}
+  startHasqlPoolStatsReporter pool
+  pure pool
+  where
+    instrumentedConnectionGetter metrics getter = do
+      started <- getMonotonicTime
+      res <- getter
+      ended <- getMonotonicTime
+      recordHasqlPoolConnectionAcquisition metrics (ended - started)
+      case res of
+        Right _ -> recordHasqlPoolConnectionEstablished metrics
+        Left _ -> recordHasqlPoolConnectionFailure metrics
+      pure res
+
+    mkHasqlPoolMetrics :: IO HasqlPoolMetrics
+    mkHasqlPoolMetrics =
+      HasqlPoolMetrics
+        <$> register (gauge $ Info "wire_hasql_pool_ready_for_use" "Number of hasql pool connections ready for use")
+        <*> register (gauge $ Info "wire_hasql_pool_in_use" "Number of hasql pool connections in use")
+        <*> register (counter $ Info "wire_hasql_pool_connection_established_count" "Number of established connections")
+        <*> register (counter $ Info "wire_hasql_pool_connection_failure_count" "Number of failed connection acquisition attempts")
+        <*> register (counter $ Info "wire_hasql_pool_acquisition_timeout_count" "Number of pool acquisition timeouts")
+        <*> register (counter $ Info "wire_hasql_pool_session_failure_count" "Number of times a session has failed")
+        <*> register (counter $ Info "wire_hasql_pool_session_count" "Number of times a session was created")
+        <*> register (histogram (Info "wire_hasql_pool_connection_acquisition_seconds" "Time spent establishing new PostgreSQL connections") defaultBuckets)
+        <*> register (histogram (Info "wire_hasql_pool_session_seconds" "Time spent using PostgreSQL sessions") defaultBuckets)
+
+    unusedSettings =
+      -- The custom getter above performs the actual connection establishment.
+      -- The API forces us to pass this record, but it is actually not used in acquireWith
+      HasqlPool.ConnectionSettings
+        { host = "",
+          port = 5432,
+          user = "",
+          password = "",
+          dbName = "",
+          connAcqTimeout = 0,
+          txIdleTimeout = HasqlPool.TimeoutSetting 0 HasqlPool.Seconds,
+          stmtTimeout = HasqlPool.TimeoutSetting 0 HasqlPool.Seconds,
+          sslMode = "prefer",
+          sslRootCert = ""
+        }
diff --git a/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs b/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs
index 4b17ad3e597..67028163553 100644
--- a/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs
+++ b/libs/extended/test/Test/Data/Hourglass/ConstSpec.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Data.Hourglass.ConstSpec where
 
 import Data.Hourglass
diff --git a/libs/extended/test/Test/Data/X509/ExtendedSpec.hs b/libs/extended/test/Test/Data/X509/ExtendedSpec.hs
index abe4ad2cd06..d1fce5ba2a9 100644
--- a/libs/extended/test/Test/Data/X509/ExtendedSpec.hs
+++ b/libs/extended/test/Test/Data/X509/ExtendedSpec.hs
@@ -1,5 +1,22 @@
 {-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-}
 
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Test.Data.X509.ExtendedSpec where
 
 import Crypto.Hash.Algorithms (SHA256 (SHA256))
diff --git a/libs/hscim/default.nix b/libs/hscim/default.nix
index 82bf4a64981..10e75c4fd29 100644
--- a/libs/hscim/default.nix
+++ b/libs/hscim/default.nix
@@ -31,7 +31,9 @@
 , mmorph
 , mtl
 , network-uri
+, openapi3
 , retry
+, schema-profunctor
 , scientific
 , servant
 , servant-client
@@ -79,7 +81,9 @@ mkDerivation {
     mmorph
     mtl
     network-uri
+    openapi3
     retry
+    schema-profunctor
     scientific
     servant
     servant-client
diff --git a/libs/hscim/hscim.cabal b/libs/hscim/hscim.cabal
index 96d62972d02..0dcc16a742a 100644
--- a/libs/hscim/hscim.cabal
+++ b/libs/hscim/hscim.cabal
@@ -61,10 +61,12 @@ library
     Web.Scim.Schema.User.Address
     Web.Scim.Schema.User.Certificate
     Web.Scim.Schema.User.Email
+    Web.Scim.Schema.User.Entitlement
     Web.Scim.Schema.User.IM
     Web.Scim.Schema.User.Name
     Web.Scim.Schema.User.Phone
     Web.Scim.Schema.User.Photo
+    Web.Scim.Schema.User.Role
     Web.Scim.Schema.UserTypes
     Web.Scim.Server
     Web.Scim.Server.Mock
@@ -114,7 +116,9 @@ library
     , mmorph
     , mtl
     , network-uri
+    , openapi3
     , retry
+    , schema-profunctor
     , scientific
     , servant
     , servant-client
diff --git a/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs b/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs
index 4bdaf265af3..4d8e812f042 100644
--- a/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs
+++ b/libs/hscim/src/Web/Scim/Capabilities/MetaSchema.hs
@@ -18,6 +18,7 @@
 module Web.Scim.Capabilities.MetaSchema
   ( ConfigSite,
     configServer,
+    defaultResourceTypes,
     Supported (..),
     BulkConfig (..),
     FilterConfig (..),
@@ -133,11 +134,19 @@ empty =
       authenticationSchemes = [AuthScheme.authHttpBasicEncoding]
     }
 
+-- | The resource types advertised by a server that has not customised them:
+-- the plain @User@ and @Group@ resources with no schema extensions.
+defaultResourceTypes :: [Resource]
+defaultResourceTypes = [usersResource, groupsResource]
+
 configServer ::
   (Monad m) =>
   Configuration ->
+  -- | The resource types to advertise at @/ResourceTypes@.  Pass
+  -- 'defaultResourceTypes' unless the server supports schema extensions.
+  [Resource] ->
   ConfigSite (AsServerT (ScimHandler m))
-configServer config =
+configServer config resourceTypes' =
   ConfigSite
     { spConfig = pure config,
       getSchemas =
@@ -152,12 +161,7 @@ configServer config =
       schema = \uri -> case getSchema (fromSchemaUri uri) of
         Nothing -> throwScim (notFound "Schema" uri)
         Just s -> pure s,
-      resourceTypes =
-        pure $
-          ListResponse.fromList
-            [ usersResource,
-              groupsResource
-            ]
+      resourceTypes = pure $ ListResponse.fromList resourceTypes'
     }
 
 data ConfigSite route = ConfigSite
diff --git a/libs/hscim/src/Web/Scim/Filter.hs b/libs/hscim/src/Web/Scim/Filter.hs
index 5862f6a36bf..e9947556689 100644
--- a/libs/hscim/src/Web/Scim/Filter.hs
+++ b/libs/hscim/src/Web/Scim/Filter.hs
@@ -128,7 +128,10 @@ data Filter
 -- TODO(arianvp): This is a slight simplification at the moment as we
 -- don't support the complete Filter grammar. This should be a
 -- valFilter, not a FILTER.
-data ValuePath = ValuePath AttrPath Filter
+data ValuePath = ValuePath
+  { valuePathAttrPath :: AttrPath,
+    valuePathFilter :: Filter
+  }
   deriving (Eq, Show)
 
 -- | subAttr   = "." ATTRNAME
diff --git a/libs/hscim/src/Web/Scim/Schema/Common.hs b/libs/hscim/src/Web/Scim/Schema/Common.hs
index c0adb84c215..67c443d73c4 100644
--- a/libs/hscim/src/Web/Scim/Schema/Common.hs
+++ b/libs/hscim/src/Web/Scim/Schema/Common.hs
@@ -1,5 +1,4 @@
-{-# LANGUAGE DerivingStrategies #-}
-{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE DerivingVia #-}
 {-# LANGUAGE TupleSections #-}
 {-# LANGUAGE ViewPatterns #-}
 
@@ -25,11 +24,15 @@ module Web.Scim.Schema.Common where
 import Data.Aeson
 import qualified Data.Aeson.Key as Key
 import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Aeson.Types (Parser)
 import qualified Data.CaseInsensitive as CI
 import Data.List (nub, (\\))
+import qualified Data.OpenApi as S
+import Data.Schema (NamedSwaggerDoc, Schema (..), ToSchema (..), mkSchema, swaggerDoc)
 import Data.String.Conversions (cs)
 import Data.Text (Text, pack, unpack)
 import qualified Data.Text as Text
+import Lens.Micro ((&), (?~))
 import qualified Network.URI as Network
 
 data WithId id a = WithId
@@ -66,16 +69,30 @@ instance ToJSON URI where
 
 newtype ScimBool = ScimBool {unScimBool :: Bool}
   deriving stock (Eq, Show, Ord)
-  deriving newtype (ToJSON)
-
-instance FromJSON ScimBool where
-  parseJSON (Bool bl) = pure (ScimBool bl)
-  parseJSON (String str) =
-    case CI.mk str of
-      "true" -> pure (ScimBool True)
-      "false" -> pure (ScimBool False)
-      _ -> fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> cs str
-  parseJSON bad = fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> show bad
+  deriving (FromJSON, ToJSON) via (Schema ScimBool)
+
+-- | Serialises to a plain JSON boolean; parses either a JSON boolean or the
+-- (case-insensitive) strings @"true"@ / @"false"@.
+instance ToSchema ScimBool where
+  schema = mkSchema desc parse (Just . toJSON . unScimBool)
+    where
+      parse :: Value -> Parser ScimBool
+      parse (Bool bl) = pure (ScimBool bl)
+      parse (String str) =
+        case CI.mk str of
+          "true" -> pure (ScimBool True)
+          "false" -> pure (ScimBool False)
+          _ -> fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> cs str
+      parse bad = fail $ "Expected true, false, \"true\", or \"false\" (case insensitive), but got " <> show bad
+
+      -- The renderer always produces a JSON boolean, so the schema type is
+      -- @boolean@; the extra string inputs the parser tolerates are documented
+      -- in the description.
+      desc :: NamedSwaggerDoc
+      desc =
+        swaggerDoc @Bool
+          & (S.schema . S.description)
+            ?~ "On input, the JSON strings \"true\" and \"false\" (case-insensitive) are also accepted."
 
 toKeyword :: String -> String
 toKeyword "typ" = "type"
diff --git a/libs/hscim/src/Web/Scim/Schema/PatchOp.hs b/libs/hscim/src/Web/Scim/Schema/PatchOp.hs
index 1ac01c3b166..63c96f38870 100644
--- a/libs/hscim/src/Web/Scim/Schema/PatchOp.hs
+++ b/libs/hscim/src/Web/Scim/Schema/PatchOp.hs
@@ -22,7 +22,7 @@ import Control.Monad (guard)
 import Control.Monad.Except
 import qualified Data.Aeson.Key as Key
 import qualified Data.Aeson.KeyMap as KeyMap
-import Data.Aeson.Types (FromJSON (parseJSON), ToJSON (toJSON), Value (String), object, withObject, withText, (.:), (.:?), (.=))
+import Data.Aeson.Types (FromJSON (parseJSON), ToJSON (toJSON), Value (String), object, withObject, withText, (.:), (.=))
 import qualified Data.Aeson.Types as Aeson
 import Data.Attoparsec.ByteString (Parser, endOfInput, parseOnly)
 import Data.Bifunctor (first)
@@ -107,7 +107,12 @@ operationFromJSON schemas' =
     Operation
       <$> (o .: "op")
       <*> Aeson.explicitParseFieldMaybe (pathFromJSON schemas') o "path"
-      <*> (o .:? "value")
+      -- RFC 7643 §2.5 (Unassigned and Null Values): "Unassigned attributes,
+      -- the null value, or an empty array [...] SHALL be considered to be
+      -- equivalent in 'state'." Keep an explicit `"value": null` (Just Null)
+      -- distinct from an absent `value` member (Nothing) so consumers can
+      -- implement null-unassignment.
+      <*> pure (KeyMap.lookup "value" o)
 
 pathFromJSON :: [Schema] -> Value -> Aeson.Parser Path
 pathFromJSON schemas' =
diff --git a/libs/hscim/src/Web/Scim/Schema/ResourceType.hs b/libs/hscim/src/Web/Scim/Schema/ResourceType.hs
index 2020e3c50a3..37fa5e022c8 100644
--- a/libs/hscim/src/Web/Scim/Schema/ResourceType.hs
+++ b/libs/hscim/src/Web/Scim/Schema/ResourceType.hs
@@ -45,18 +45,52 @@ instance FromJSON ResourceType where
     other -> fail ("unknown ResourceType: " ++ show other)
 
 -- | Definitions of endpoints, returned by @/ResourceTypes@.
+-- | A schema extension advertised by a 'Resource', as defined in RFC 7643
+-- section 6.  Serialises to @{"schema": , "required": }@.
+data SchemaExtension = SchemaExtension
+  { schemaExtensionSchema :: Schema,
+    schemaExtensionRequired :: Bool
+  }
+  deriving (Show, Eq, Generic)
+
+instance ToJSON SchemaExtension where
+  toJSON (SchemaExtension sch req) =
+    object ["schema" .= sch, "required" .= req]
+
+instance FromJSON SchemaExtension where
+  parseJSON = either (fail . show) go . jsonLower
+    where
+      go = withObject "SchemaExtension" $ \o ->
+        SchemaExtension <$> o .: "schema" <*> o .:? "required" .!= False
+
 data Resource = Resource
   { name :: Text,
     endpoint :: URI,
-    schema :: Schema
+    schema :: Schema,
+    schemaExtensions :: [SchemaExtension]
   }
   deriving (Show, Eq, Generic)
 
 instance ToJSON Resource where
-  toJSON = genericToJSON serializeOptions
+  toJSON (Resource name' endpoint' schema' exts) =
+    object $
+      [ "name" .= name',
+        "endpoint" .= endpoint',
+        "schema" .= schema'
+      ]
+        -- omit the field entirely when there are no extensions, so resources
+        -- without extensions keep their previous representation.
+        <> ["schemaExtensions" .= exts | not (null exts)]
 
 instance FromJSON Resource where
-  parseJSON = either (fail . show) (genericParseJSON parseOptions) . jsonLower
+  parseJSON = either (fail . show) go . jsonLower
+    where
+      go = withObject "Resource" $ \o ->
+        Resource
+          <$> o .: "name"
+          <*> o .: "endpoint"
+          <*> o .: "schema"
+          <*> o .:? "schemaextensions" .!= []
 
 ----------------------------------------------------------------------------
 -- Available resource endpoints
@@ -66,7 +100,8 @@ usersResource =
   Resource
     { name = "User",
       endpoint = URI [relativeReference|/Users|],
-      schema = User20
+      schema = User20,
+      schemaExtensions = []
     }
 
 groupsResource :: Resource
@@ -74,5 +109,6 @@ groupsResource =
   Resource
     { name = "Group",
       endpoint = URI [relativeReference|/Groups|],
-      schema = Group20
+      schema = Group20,
+      schemaExtensions = []
     }
diff --git a/libs/hscim/src/Web/Scim/Schema/User.hs b/libs/hscim/src/Web/Scim/Schema/User.hs
index 1a37f6dae60..89dc345a10a 100644
--- a/libs/hscim/src/Web/Scim/Schema/User.hs
+++ b/libs/hscim/src/Web/Scim/Schema/User.hs
@@ -54,6 +54,13 @@
 --  and all the others are either implied 'primary: false' or must be checked
 --  that they're false
 --
+--  (Partially addressed for @emails@: at most one @primary: true@ entry is
+--  now enforced at selection time in
+--  "Web.Scim.Schema.User.Email".'Web.Scim.Schema.User.Email.scimEmailsToEmail'
+--  (and 'Web.Scim.Schema.User.Email.scimEmailsToEmailAddress'), which
+--  rejects a multi-primary @emails@ list instead of silently picking
+--  one. Other multi-valued attributes are not yet validated.)
+--
 --
 -- == Attribute names
 --
@@ -77,23 +84,36 @@ import Data.Aeson
 import qualified Data.Aeson.Key as Key
 import qualified Data.Aeson.KeyMap as KeyMap
 import Data.List ((\\))
+import Data.Maybe (fromMaybe)
 import Data.Text (Text, pack)
 import qualified Data.Text as Text
+import Data.Text.Encoding (decodeUtf8)
 import GHC.Generics (Generic)
 import Lens.Micro
+import qualified Text.Email.Validate as EmailValidate
 import Web.Scim.AttrName
-import Web.Scim.Filter (AttrPath (..))
+import Web.Scim.Filter
+  ( AttrPath (..),
+    CompValue (..),
+    CompareOp (..),
+    Filter (..),
+    SubAttr (..),
+    ValuePath (..),
+    compareStr,
+  )
 import Web.Scim.Schema.Common
 import Web.Scim.Schema.Error
 import Web.Scim.Schema.PatchOp
 import Web.Scim.Schema.Schema (Schema (..), getSchemaUri)
 import Web.Scim.Schema.User.Address (Address)
 import Web.Scim.Schema.User.Certificate (Certificate)
-import Web.Scim.Schema.User.Email (Email)
+import Web.Scim.Schema.User.Email (Email (Email, primary, typ), EmailAddress (..))
+import Web.Scim.Schema.User.Entitlement (Entitlement)
 import Web.Scim.Schema.User.IM (IM)
 import Web.Scim.Schema.User.Name (Name)
 import Web.Scim.Schema.User.Phone (Phone)
 import Web.Scim.Schema.User.Photo (Photo)
+import Web.Scim.Schema.User.Role (Role)
 import Web.Scim.Schema.UserTypes
 
 -- | SCIM user record, parametrized with type-level @tag@ (see 'UserTypes').
@@ -119,8 +139,8 @@ data User tag = User
     ims :: [IM],
     photos :: [Photo],
     addresses :: [Address],
-    entitlements :: [Text],
-    roles :: [Text],
+    entitlements :: [Entitlement],
+    roles :: [Role],
     x509Certificates :: [Certificate],
     -- Extra data.
     --
@@ -305,6 +325,24 @@ applyUserOperation ::
   User tag ->
   Operation ->
   m (User tag)
+applyUserOperation user (Operation Add (Just (IntoValuePath vp mSub)) (Just val)) =
+  case vp of
+    ValuePath (AttrPath _ attr _) _
+      | attr == "emails" -> addEmailsValuePath user vp mSub val
+      | otherwise ->
+          throwError
+            ( badRequest
+                InvalidPath
+                (Just "multi-valued PATCH is only supported for 'emails'")
+            )
+-- Catch-all: for single-valued 'NormalPath' attributes (username, displayname,
+-- externalid, active) an @Add@ coincides with a @Replace@ (RFC 7644 §3.5.2.1:
+-- a single-valued target has its value replaced). @roles@ is multi-valued
+-- (@[Text]@), so the rewrite turns an RFC-mandated append into an overwrite --
+-- a known deviation, acceptable while no client relies on append semantics for
+-- @roles@. Multi-valued value-path @Add@ is intercepted above; any future
+-- complex or multi-valued attribute added to 'NormalPath' must not rely on
+-- this rewrite.
 applyUserOperation user (Operation Add path value) = applyUserOperation user (Operation Replace path value)
 applyUserOperation user (Operation Replace (Just (NormalPath (AttrPath _schema attr _subAttr))) (Just value)) =
   case attr of
@@ -319,8 +357,16 @@ applyUserOperation user (Operation Replace (Just (NormalPath (AttrPath _schema a
     "roles" ->
       (\x -> user {roles = x}) <$> resultToScimError (fromJSON value)
     _ -> throwError (badRequest InvalidPath (Just "we only support attributes username, displayname, externalid, active, roles"))
-applyUserOperation _ (Operation Replace (Just (IntoValuePath _ _)) _) = do
-  throwError (badRequest InvalidPath (Just "can not lens into multi-valued attributes yet"))
+applyUserOperation user (Operation Replace (Just (IntoValuePath vp mSub)) (Just val)) =
+  case vp of
+    ValuePath (AttrPath _ attr _) _
+      | attr == "emails" -> replaceEmailsValuePath user vp mSub val
+      | otherwise ->
+          throwError
+            ( badRequest
+                InvalidPath
+                (Just "multi-valued PATCH is only supported for 'emails'")
+            )
 applyUserOperation user (Operation Replace Nothing (Just value)) = do
   case value of
     Object hm | null ((AttrName . Key.toText <$> KeyMap.keys hm) \\ ["username", "displayname", "externalid", "active", "roles"]) -> do
@@ -336,16 +382,248 @@ applyUserOperation user (Operation Replace Nothing (Just value)) = do
 applyUserOperation _ (Operation Replace _ Nothing) =
   throwError (badRequest InvalidValue (Just "No value was provided"))
 applyUserOperation _ (Operation Remove Nothing _) = throwError (badRequest NoTarget Nothing)
-applyUserOperation user (Operation Remove (Just (NormalPath (AttrPath _schema attr _subAttr))) _value) =
+-- RFC 7644 §3.5.2.2 (Remove Operation): "If the target location is a
+-- single-value attribute, the attribute and its associated value is removed,
+-- and the attribute SHALL be considered unassigned." and: "If the target
+-- location is a multi-valued attribute and no filter is specified, the
+-- attribute and all values are removed, and the attribute SHALL be considered
+-- unassigned."
+--
+-- RFC 7643 §2.5 (Unassigned and Null Values): "Unassigned attributes, the
+-- null value, or an empty array [...] SHALL be considered to be equivalent
+-- in 'state'.  Assigning an attribute with the value 'null' [...] has the
+-- effect of making the attribute 'unassigned'."  Consequently, a @Replace@
+-- with an explicit @null@ value on a 'Maybe'-typed attribute here
+-- (displayname, externalid, active) unassigns it, exactly as @Remove@ does.
+applyUserOperation user (Operation Remove (Just (NormalPath (AttrPath _schema attr mSubAttr))) _value) =
   case attr of
     "username" -> throwError (badRequest Mutability Nothing)
     "displayname" -> pure $ user {displayName = Nothing}
     "externalid" -> pure $ user {externalId = Nothing}
     "active" -> pure $ user {active = Nothing}
     "roles" -> pure $ user {roles = []}
+    "emails" -> case mSubAttr of
+      -- Unfiltered remove of the multi-valued attribute itself.
+      Nothing -> pure $ user {emails = []}
+      -- A sub-attribute without a filter would have to hit every entry;
+      -- require the explicit value-path form instead (and keep @.value@
+      -- protected there).
+      Just _ ->
+        throwError
+          ( badRequest
+              InvalidPath
+              (Just "removing a sub-attribute of 'emails' requires a value-path filter, e.g. emails[type eq \\\"work\\\"].type")
+          )
     _ -> pure user
-applyUserOperation _ (Operation Remove (Just (IntoValuePath _ _)) _) = do
-  throwError (badRequest InvalidPath (Just "can not lens into multi-valued attributes yet"))
+-- RFC 7644 §3.5.2.2 (Remove Operation): "If the target location is a complex
+-- multi-valued attribute and a complex filter is specified based on the
+-- attribute's sub-attributes, the matching records are removed.
+-- Sub-attributes whose values have been removed SHALL be considered
+-- unassigned."  With a sub-attribute target (RFC 7644 §3.5.2: PATH = attrPath
+-- / valuePath [subAttr], Figure 7), the sub-attribute becomes *unassigned* on
+-- each matching entry and the record itself is kept -- implemented by reusing
+-- the null-unassignment of 'replaceEmailsValuePath' (RFC 7643 §2.5: remove of
+-- a sub-attribute is equivalent to assigning null).
+applyUserOperation user (Operation Remove (Just (IntoValuePath vp mSub)) _) =
+  case vp of
+    ValuePath (AttrPath _ attr _) _
+      | attr == "emails" -> case mSub of
+          Just (SubAttr sub)
+            | sub == "value" ->
+                throwError
+                  ( badRequest
+                      InvalidPath
+                      (Just "removing the 'value' sub-attribute of 'emails' is not supported; remove the whole entry instead")
+                  )
+            | otherwise -> replaceEmailsValuePath user vp mSub Null
+          Nothing -> pure user {emails = removeMatchingEmails vp (emails user)}
+      | otherwise ->
+          throwError
+            ( badRequest
+                InvalidPath
+                (Just "multi-valued PATCH is only supported for 'emails'")
+            )
+
+----------------------------------------------------------------------------
+-- Multi-valued 'emails' value-path PATCH
+--
+-- Previously any value-path target (e.g. @emails[type eq "work"].value@) was
+-- rejected with "can not lens into multi-valued attributes yet". We now support
+-- value-path PATCH for the @emails@ attribute only -- the single multi-valued
+-- attribute that Spar persists. Other multi-valued attributes
+-- (@phoneNumbers@, @ims@, ...) remain unsupported and still fail as before.
+--
+-- NOTE on "create on absent": RFC 7644 §3.5.2.3 says a value-path @Replace@
+-- that matches nothing is a no-op. Microsoft Entra ID, however, provisions the
+-- email address with an @Add@ against @emails[type eq "work"].value@ (Entra uses
+-- @Add@ for both insert and update -- see
+-- ),
+-- expecting the entry to be created if absent. Both 'addEmailsValuePath' and the
+-- @Replace@ path therefore route the @.value@ sub-attribute through
+-- 'replaceEmailValue', which deviates from the RFC: when the filter is
+-- @type eq @ and no entry matches, it appends
+-- @Email { typ = Just s, value = newVal, primary = Nothing }@.
+--
+-- @Remove@ honors the sub-attribute (RFC 7644 §3.5.2: PATH = valuePath
+-- [subAttr]): @remove emails[type eq "work"].type@\/@.primary@ unassigns just
+-- that field and keeps the entry; a filterless @remove emails@ clears all
+-- entries. @.value@ cannot be removed or nulled (400) -- the address is the
+-- record's identity in brig.
+
+-- | Textual form of an 'Email' address, for string comparison.
+emailValueText :: Email -> Text
+emailValueText (Email _ addr _) =
+  decodeUtf8 (EmailValidate.toByteString (unEmailAddress addr))
+
+-- | Does this 'Email' satisfy the given single-attribute 'Filter'? Supports the
+-- sub-attributes Entra and the spec use: @type@, @value@, @primary@. Any
+-- operator in 'compareStr's domain works for @type@\/@value@; @primary@ only
+-- supports @eq@\/@ne@. Unknown sub-attributes or a mismatched 'CompValue' type
+-- mean "no match".
+emailMatches :: Filter -> Email -> Bool
+emailMatches (FilterAttrCompare (AttrPath _ attr _) op cval) email
+  | attr == "type" = case cval of
+      ValString s -> compareStr op (fromMaybe "" (typ email)) s
+      _ -> False
+  | attr == "value" = case cval of
+      ValString s -> compareStr op (emailValueText email) s
+      _ -> False
+  | attr == "primary" = case cval of
+      ValBool b -> primaryMatches op b (primary email)
+      _ -> False
+  | otherwise = False
+
+-- | Compare a @primary@ filter value. Only @eq@\/@ne@ are meaningful.
+primaryMatches :: CompareOp -> Bool -> Maybe ScimBool -> Bool
+primaryMatches op b mp = case op of
+  OpEq -> mp == Just (ScimBool b)
+  OpNe -> mp /= Just (ScimBool b)
+  _ -> False
+
+-- | If the filter is @type eq @, return @Just s@; otherwise 'Nothing'.
+-- Drives create-on-absent for the @.value@ sub-attribute (see note above).
+filterTypeEq :: Filter -> Maybe Text
+filterTypeEq (FilterAttrCompare (AttrPath _ attr _) OpEq (ValString s))
+  | attr == "type" = Just s
+filterTypeEq _ = Nothing
+
+-- | Apply an update to each matching email. Never creates new entries.
+setEmailField :: Filter -> (Email -> Email) -> [Email] -> [Email]
+setEmailField flt update = map (\e -> if emailMatches flt e then update e else e)
+
+-- | Set the address of an 'Email'. Uses positional construction to avoid the
+-- bare 'value' selector, which is ambiguous (shared by 'Email', 'WithId' and
+-- 'Operation').
+setEmailAddress :: EmailAddress -> Email -> Email
+setEmailAddress newAddr (Email t _ p) = Email t newAddr p
+
+-- | Replace the @.value@ of every matching email. When nothing matches and the
+-- filter is @type eq @, append a new entry (create-on-absent; see note).
+replaceEmailValue :: Filter -> EmailAddress -> [Email] -> [Email]
+replaceEmailValue flt newAddr es
+  | any (emailMatches flt) es = setEmailField flt (setEmailAddress newAddr) es
+  | otherwise =
+      case filterTypeEq flt of
+        Just t -> es <> [Email (Just t) newAddr Nothing]
+        Nothing -> es
+
+-- | Replace each whole matching email with a new one; append if none match.
+--
+-- NOTE: every entry that matches the filter is overwritten with the same
+-- @newEmail@, so a filter matching several entries (e.g. two with
+-- @type eq "work"@, which Spar does not prevent) collapses them into
+-- duplicates. In practice each @type@ has at most one entry (the only mapping
+-- Entra uses), so this does not arise.
+replaceEmailEntry :: Filter -> Email -> [Email] -> [Email]
+replaceEmailEntry flt newEmail es
+  | any (emailMatches flt) es = setEmailField flt (const newEmail) es
+  | otherwise = es <> [newEmail]
+
+-- | Decode the operation value as one or more emails. A bare object is treated
+-- as a single-element list; an array is decoded as-is.
+decodeEmails :: (MonadError ScimError m) => Value -> m [Email]
+decodeEmails val = case fromJSON val of
+  Success (es' :: [Email]) -> pure es'
+  _ -> (: []) <$> resultToScimError (fromJSON val)
+
+-- | Handle an @Add@ on an @emails[...]@ value-path.
+--
+-- For the single-valued email sub-attributes (@.value@, @.type@, @.primary@) an
+-- @Add@ coincides with a @Replace@ (RFC 7644 §3.5.2.3): it sets the
+-- sub-attribute and, for @.value@, creates the entry on absent via
+-- 'replaceEmailValue'. For a whole-entry @Add@ (no sub-attribute) the value-path
+-- filter is intentionally ignored and the new entries are /appended/ rather than
+-- overwriting matches -- the concat semantics that distinguish @Add@ from
+-- @Replace@ for multi-valued attributes (where @Replace@ narrows the target set
+-- via the filter).
+addEmailsValuePath ::
+  (MonadError ScimError m) =>
+  User tag ->
+  ValuePath ->
+  Maybe SubAttr ->
+  Value ->
+  m (User tag)
+addEmailsValuePath user vp mSub val =
+  case mSub of
+    Just _ -> replaceEmailsValuePath user vp mSub val
+    Nothing -> do
+      newEmails <- decodeEmails val
+      pure user {emails = emails user <> newEmails}
+
+-- | Handle a @Replace@ on an @emails[...]@ value-path.
+replaceEmailsValuePath ::
+  (MonadError ScimError m) =>
+  User tag ->
+  ValuePath ->
+  Maybe SubAttr ->
+  Value ->
+  m (User tag)
+replaceEmailsValuePath user vp mSub val =
+  let flt = valuePathFilter vp
+      es = emails user
+   in case mSub of
+        Just (SubAttr sub)
+          -- RFC 7643 §2.5 (Unassigned and Null Values): "Assigning an
+          -- attribute with the value 'null' or an empty array [...] has the
+          -- effect of making the attribute 'unassigned'." Assigning an
+          -- explicit null therefore unassigns 'type'/'primary'; 'value' cannot
+          -- be unassigned (the address is the record's identity in brig).
+          | sub == "value" -> case val of
+              Null ->
+                throwError
+                  ( badRequest
+                      InvalidPath
+                      (Just "setting the 'value' sub-attribute of 'emails' to null is not supported; remove the whole entry instead")
+                  )
+              _ -> do
+                newAddr <- resultToScimError (fromJSON val)
+                pure user {emails = replaceEmailValue flt newAddr es}
+          | sub == "type" -> case val of
+              Null -> pure user {emails = setEmailField flt (\e -> e {typ = Nothing}) es}
+              _ -> do
+                t <- resultToScimError (fromJSON val)
+                pure user {emails = setEmailField flt (\e -> e {typ = Just t}) es}
+          | sub == "primary" -> case val of
+              Null -> pure user {emails = setEmailField flt (\e -> e {primary = Nothing}) es}
+              _ -> do
+                b <- resultToScimError (fromJSON val)
+                pure user {emails = setEmailField flt (\e -> e {primary = Just b}) es}
+          | otherwise ->
+              throwError
+                ( badRequest
+                    InvalidPath
+                    (Just "only the 'value', 'type' and 'primary' sub-attributes of 'emails' can be patched")
+                )
+        Nothing -> do
+          newEmails <- decodeEmails val
+          pure user {emails = foldr (replaceEmailEntry flt) es newEmails}
+
+-- | Drop every email matching the value-path filter (used by @Remove@ without a
+-- sub-attribute). A @Remove@ targeting a sub-attribute (e.g.
+-- @emails[type eq "work"].type@) is handled in 'applyUserOperation', which
+-- unassigns just that field and keeps the entry (RFC 7644 §3.5.2.2).
+removeMatchingEmails :: ValuePath -> [Email] -> [Email]
+removeMatchingEmails vp = filter (not . emailMatches (valuePathFilter vp))
 
 instance (UserTypes tag, FromJSON (User tag), Patchable (UserExtra tag)) => Patchable (User tag) where
   applyOperation user op@(Operation _ (Just (NormalPath (AttrPath schema _ _))) _)
diff --git a/libs/hscim/src/Web/Scim/Schema/User/Email.hs b/libs/hscim/src/Web/Scim/Schema/User/Email.hs
index 0b8bf7e919b..b893552ae3a 100644
--- a/libs/hscim/src/Web/Scim/Schema/User/Email.hs
+++ b/libs/hscim/src/Web/Scim/Schema/User/Email.hs
@@ -17,7 +17,6 @@
 
 module Web.Scim.Schema.User.Email where
 
-import Control.Applicative ((<|>))
 import Data.Aeson
 import Data.Text hiding (dropWhile, show)
 import Data.Text.Encoding (decodeUtf8, encodeUtf8)
@@ -53,12 +52,33 @@ instance ToJSON Email where
 emailToEmailAddress :: Email -> Email.EmailAddress
 emailToEmailAddress = unEmailAddress . value
 
-scimEmailsToEmailAddress :: [Email] -> Maybe Email.EmailAddress
-scimEmailsToEmailAddress es = pickPrimary es <|> pickFirst es
+-- | Pick the 'Email' entry whose address Wire stores: the single entry marked
+-- @primary@ (RFC 7643 §2.4 allows at most one), else the first entry.
+--
+-- Wire/brig holds at most one email per user, so the (possibly multi-valued)
+-- SCIM @emails@ attribute must be reduced to one entry. Selection rule:
+-- the entry marked @primary@ (RFC 7643 §2.4: @primary@ value @true@ MUST
+-- appear no more than once), else the first entry. Per RFC 7643 §2.4 an
+-- absent @primary@ is assumed @false@; with none marked primary, Wire
+-- deterministically picks the first entry (it must store exactly one email).
+--
+-- If more than one entry is marked @primary@ — a client-side protocol
+-- violation — this returns 'Left' with a descriptive message so the caller
+-- rejects the request instead of silently picking one.
+scimEmailsToEmail :: [Email] -> Either Text (Maybe Email)
+scimEmailsToEmail es = case primaries of
+  [primaryEmail] -> Right (Just primaryEmail)
+  [] -> Right (firstEntry es)
+  _ -> Left "More than one email is marked as primary; RFC 7643 §2.4 allows at most one."
   where
-    pickFirst [] = Nothing
-    pickFirst (e : _) = Just (unEmailAddress (value e))
+    firstEntry [] = Nothing
+    firstEntry (e : _) = Just e
 
-    pickPrimary = pickFirst . Prelude.filter isPrimary
+    primaries = Prelude.filter isPrimary es
 
     isPrimary e = primary e == Just (ScimBool True)
+
+-- | Reduce a list of SCIM emails to the single address Wire stores (see
+-- 'scimEmailsToEmail' for the selection rule).
+scimEmailsToEmailAddress :: [Email] -> Either Text (Maybe Email.EmailAddress)
+scimEmailsToEmailAddress = fmap (fmap emailToEmailAddress) . scimEmailsToEmail
diff --git a/libs/hscim/src/Web/Scim/Schema/User/Entitlement.hs b/libs/hscim/src/Web/Scim/Schema/User/Entitlement.hs
new file mode 100644
index 00000000000..c0ca03eadc2
--- /dev/null
+++ b/libs/hscim/src/Web/Scim/Schema/User/Entitlement.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE DerivingVia #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Web.Scim.Schema.User.Entitlement where
+
+import Data.Aeson (FromJSON (..), ToJSON, Value (String))
+import qualified Data.OpenApi as S
+import Data.Schema
+import Data.Text (Text)
+import Web.Scim.Schema.Common (ScimBool (..))
+
+-- | A SCIM @entitlements@ entry. RFC 7643 defines @entitlements@ as a complex,
+-- multi-valued attribute, so each element is an object with (optional)
+-- sub-attributes rather than a bare string.
+data Entitlement = Entitlement
+  { value :: Maybe Text,
+    typ :: Maybe Text,
+    display :: Maybe Text,
+    primary :: Maybe ScimBool
+  }
+  deriving stock (Show, Eq)
+  deriving (ToJSON, S.ToSchema) via (Schema Entitlement)
+
+instance ToSchema Entitlement where
+  schema =
+    object
+      ( Entitlement
+          <$> (value .= maybe_ (optField "value" schema))
+          <*> (typ .= maybe_ (optField "type" schema))
+          <*> (display .= maybe_ (optField "display" schema))
+          <*> (primary .= maybe_ (optField "primary" schema))
+      )
+
+-- | We accept both the RFC-compliant object form (parsed via the schema above)
+-- and a plain string (for backwards compatibility with clients that send
+-- @"entitlements": ["some entitlement"]@).
+instance FromJSON Entitlement where
+  parseJSON (String s) = pure $ Entitlement (Just s) Nothing Nothing Nothing
+  parseJSON v = schemaIn (schema @Entitlement) v
diff --git a/libs/hscim/src/Web/Scim/Schema/User/Role.hs b/libs/hscim/src/Web/Scim/Schema/User/Role.hs
new file mode 100644
index 00000000000..f18ccf8362e
--- /dev/null
+++ b/libs/hscim/src/Web/Scim/Schema/User/Role.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE DerivingVia #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2025 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Web.Scim.Schema.User.Role where
+
+import Data.Aeson (FromJSON (..), ToJSON, Value (String))
+import qualified Data.OpenApi as S
+import Data.Schema
+import Data.Text (Text)
+import Web.Scim.Schema.Common (ScimBool (..))
+
+-- | A SCIM @roles@ entry. RFC 7643 defines @roles@ as a complex, multi-valued
+-- attribute, so each element is an object with (optional) sub-attributes rather
+-- than a bare string.
+data Role = Role
+  { value :: Maybe Text,
+    typ :: Maybe Text,
+    display :: Maybe Text,
+    primary :: Maybe ScimBool
+  }
+  deriving stock (Show, Eq)
+  deriving (ToJSON, S.ToSchema) via (Schema Role)
+
+instance ToSchema Role where
+  schema =
+    object
+      ( Role
+          <$> (value .= maybe_ (optField "value" schema))
+          <*> (typ .= maybe_ (optField "type" schema))
+          <*> (display .= maybe_ (optField "display" schema))
+          <*> (primary .= maybe_ (optField "primary" schema))
+      )
+
+-- | We accept both the RFC-compliant object form (parsed via the schema above)
+-- and a plain string (for backwards compatibility with clients that send
+-- @"roles": ["member"]@).
+instance FromJSON Role where
+  parseJSON (String s) = pure $ Role (Just s) Nothing Nothing Nothing
+  parseJSON v = schemaIn (schema @Role) v
diff --git a/libs/hscim/src/Web/Scim/Server.hs b/libs/hscim/src/Web/Scim/Server.hs
index 364f382b0fb..216dccb6594 100644
--- a/libs/hscim/src/Web/Scim/Server.hs
+++ b/libs/hscim/src/Web/Scim/Server.hs
@@ -42,7 +42,7 @@ import Network.Wai
 import Servant
 import Servant.API.Generic
 import Servant.Server.Generic
-import Web.Scim.Capabilities.MetaSchema (ConfigSite, Configuration, configServer)
+import Web.Scim.Capabilities.MetaSchema (ConfigSite, Configuration, configServer, defaultResourceTypes)
 import Web.Scim.Class.Auth (AuthDB (..), AuthTypes (..))
 import Web.Scim.Class.Group (GroupDB, GroupSite (..), GroupTypes (..), groupServer)
 import Web.Scim.Class.User (UserDB (..), UserSite (..), userServer)
@@ -90,7 +90,7 @@ siteServer ::
   Site tag (AsServerT (ScimHandler m))
 siteServer conf =
   Site
-    { config = toServant $ configServer conf,
+    { config = toServant $ configServer conf defaultResourceTypes,
       users = \authData -> toServant (userServer @tag authData),
       groups = \authData -> toServant (groupServer @tag authData)
     }
diff --git a/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs b/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs
index ec456f4d8e9..67577d4d84d 100644
--- a/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs
+++ b/libs/hscim/test/Test/Capabilities/MetaSchemaSpec.hs
@@ -39,7 +39,7 @@ import Web.Scim.Test.Util
 app :: IO Application
 app = do
   storage <- emptyTestStorage
-  pure $ mkapp @Mock (Proxy @ConfigAPI) (toServant (configServer empty)) (nt storage)
+  pure $ mkapp @Mock (Proxy @ConfigAPI) (toServant (configServer empty defaultResourceTypes)) (nt storage)
 
 shouldSatisfy ::
   (Show a, FromJSON a) =>
diff --git a/libs/hscim/test/Test/Class/UserSpec.hs b/libs/hscim/test/Test/Class/UserSpec.hs
index 6a46738dccc..de1fd973efa 100644
--- a/libs/hscim/test/Test/Class/UserSpec.hs
+++ b/libs/hscim/test/Test/Class/UserSpec.hs
@@ -328,6 +328,33 @@ spec = with app $ do
           }]}|]
           `shouldRespondWith` 400
         get "/0" `shouldRespondWith` smallUserGet {matchStatus = 200}
+      it "patches a multi-valued 'emails' value-path end-to-end" $ do
+        post "/" newBarbara `shouldRespondWith` 201
+        _ <- put "/0" smallUser
+        patch
+          "/0"
+          [scim|{
+            "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
+            "Operations": [{
+              "op": "Replace",
+              "path": "emails[type eq \"work\"].value",
+              "value": "x@y.com"
+            }]
+          }|]
+          `shouldRespondWith` [scim|{
+            "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
+            "userName": "bjensen",
+            "displayName": "bjensen2",
+            "emails": [{"value":"x@y.com","type":"work"}],
+            "id": "0",
+            "meta": {
+              "resourceType": "User",
+              "location": "https://example.com/Users/id",
+              "created": "2018-01-01T00:00:00Z",
+              "version": "W/\"testVersion\"",
+              "lastModified": "2018-01-01T00:00:00Z"
+            }
+          }|]
     describe "Remove" $ do
       it "fails if no target" $ do
         post "/" newBarbara `shouldRespondWith` 201
diff --git a/libs/hscim/test/Test/Schema/PatchOpSpec.hs b/libs/hscim/test/Test/Schema/PatchOpSpec.hs
index 2e9a0415316..f3e21bb08b9 100644
--- a/libs/hscim/test/Test/Schema/PatchOpSpec.hs
+++ b/libs/hscim/test/Test/Schema/PatchOpSpec.hs
@@ -140,3 +140,13 @@ spec = do
               "members[value eq \"2819c223-7f76-453a-919d-413861904646\"].displayname"
             ]
       for_ examples $ \p -> it ("parses " ++ show p) $ rPath <$> parseOnly (pPath (supportedSchemas @PatchTestTag)) p `shouldBe` Right (decodeUtf8 p)
+    it "keeps an explicit null 'value' distinct from an absent 'value' (RFC 7643 §2.5)" $ do
+      let p = either (error "unparseable path") id (parsePath [User20] "emails[type eq \"work\"].type")
+      Aeson.parseEither
+        (operationFromJSON [User20])
+        [scim| {"op":"replace","path":"emails[type eq \"work\"].type","value":null} |]
+        `shouldBe` Right (Operation Replace (Just p) (Just Aeson.Null))
+      Aeson.parseEither
+        (operationFromJSON [User20])
+        [scim| {"op":"replace","path":"emails[type eq \"work\"].type"} |]
+        `shouldBe` Right (Operation Replace (Just p) Nothing)
diff --git a/libs/hscim/test/Test/Schema/ResourceSpec.hs b/libs/hscim/test/Test/Schema/ResourceSpec.hs
index d699201ff9c..365d9613107 100644
--- a/libs/hscim/test/Test/Schema/ResourceSpec.hs
+++ b/libs/hscim/test/Test/Schema/ResourceSpec.hs
@@ -24,6 +24,7 @@ import Data.Aeson
 import HaskellWorks.Hspec.Hedgehog (require)
 import Hedgehog
 import qualified Hedgehog.Gen as Gen
+import qualified Hedgehog.Range as Range
 import Test.Hspec
 import Test.Schema.Util (genUri, mk_prop_caseInsensitive)
 import Web.Scim.Schema.ResourceType
@@ -38,15 +39,45 @@ spec :: Spec
 spec = do
   it "roundtrip" $ do
     require prop_roundtrip
+
   it "case-insensitive" $ do
     require $ mk_prop_caseInsensitive genResource
 
+  it "omits schemaExtensions when there are none" $ do
+    toJSON usersResource
+      `shouldBe` object
+        [ "endpoint" .= String "/Users",
+          "name" .= String "User",
+          "schema" .= String "urn:ietf:params:scim:schemas:core:2.0:User"
+        ]
+
+  it "serialises a schema extension in RFC 7643 shape" $ do
+    toJSON (SchemaExtension (Schema.CustomSchema "urn:example:X") True)
+      `shouldBe` object
+        [ "schema" .= String "urn:example:X",
+          "required" .= True
+        ]
+
+  it "user schema with extension also works" $ do
+    toJSON (usersResource {schemaExtensions = [SchemaExtension (Schema.CustomSchema "urn:example:X") True]})
+      `shouldBe` object
+        [ "endpoint" .= String "/Users",
+          "name" .= String "User",
+          "schema" .= String "urn:ietf:params:scim:schemas:core:2.0:User",
+          "schemaExtensions" .= [object ["schema" .= String "urn:example:X", "required" .= True]]
+        ]
+
 genResource :: Gen Resource
 genResource =
   Resource
     <$> Gen.element ["name1", "name2", "name3"]
     <*> genUri
     <*> genSchema
+    <*> Gen.list (Range.linear 0 3) genSchemaExtension
+
+genSchemaExtension :: Gen SchemaExtension
+genSchemaExtension =
+  SchemaExtension <$> genSchema <*> Gen.bool
 
 genSchema :: Gen Schema.Schema
 genSchema =
diff --git a/libs/hscim/test/Test/Schema/UserSpec.hs b/libs/hscim/test/Test/Schema/UserSpec.hs
index 1885060facc..dc76101c33d 100644
--- a/libs/hscim/test/Test/Schema/UserSpec.hs
+++ b/libs/hscim/test/Test/Schema/UserSpec.hs
@@ -52,10 +52,12 @@ import qualified Web.Scim.Schema.User as User
 import Web.Scim.Schema.User.Address as Address
 import Web.Scim.Schema.User.Certificate as Certificate
 import Web.Scim.Schema.User.Email as Email
+import Web.Scim.Schema.User.Entitlement as Entitlement
 import Web.Scim.Schema.User.IM as IM
 import Web.Scim.Schema.User.Name as Name
 import Web.Scim.Schema.User.Phone as Phone
 import Web.Scim.Schema.User.Photo as Photo
+import Web.Scim.Schema.User.Role as Role
 import Web.Scim.Test.Util
 
 prop_roundtrip :: Property
@@ -79,7 +81,7 @@ spec = do
         true = Just (ScimBool True)
 
     it "returns Nothing if empty" $ do
-      scimEmailsToEmailAddress [] `shouldBe` Nothing
+      scimEmailsToEmailAddress [] `shouldBe` Right Nothing
 
     it "returns first primary if it exists" $ do
       scimEmailsToEmailAddress
@@ -87,19 +89,33 @@ spec = do
           Email Nothing (EmailAddress adr2) false2,
           Email (Just "this is ignored") (EmailAddress adr3) true
         ]
-        `shouldBe` Just adr3
+        `shouldBe` Right (Just adr3)
 
     it "returns first entry if no primary exists" $ do
       scimEmailsToEmailAddress
         [ Email Nothing (EmailAddress adr1) false1,
           Email Nothing (EmailAddress adr2) false2
         ]
-        `shouldBe` Just adr1
+        `shouldBe` Right (Just adr1)
       scimEmailsToEmailAddress
         [ Email Nothing (EmailAddress adr1) false2,
           Email Nothing (EmailAddress adr2) false1
         ]
-        `shouldBe` Just adr1
+        `shouldBe` Right (Just adr1)
+
+    it "rejects when more than one email is primary" $ do
+      scimEmailsToEmailAddress
+        [ Email Nothing (EmailAddress adr1) true,
+          Email Nothing (EmailAddress adr2) true
+        ]
+        `shouldBe` Left "More than one email is marked as primary; RFC 7643 §2.4 allows at most one."
+
+    it "does not reject when one primary is true and another is false" $ do
+      scimEmailsToEmailAddress
+        [ Email Nothing (EmailAddress adr1) true,
+          Email Nothing (EmailAddress adr2) false2
+        ]
+        `shouldBe` Right (Just adr1)
 
   describe "applyPatch" $ do
     it "only applies patch for supported fields" $ do
@@ -116,6 +132,7 @@ spec = do
           let operation = Operation Replace (Just (NormalPath (AttrPath Nothing key Nothing))) (Just upd)
           let patchOp = PatchOp [operation]
           User.applyPatch user patchOp `shouldSatisfy` isRight
+
     it "does not support multi-value attributes" $ do
       let schemas' = []
       let extras = KeyMap.empty
@@ -135,13 +152,14 @@ spec = do
           ("ims", toJSON @[IM] mempty),
           ("photos", toJSON @[Photo] mempty),
           ("addresses", toJSON @[Address] mempty),
-          ("entitlements", toJSON @[Text] mempty),
+          ("entitlements", toJSON @[Entitlement] mempty),
           ("x509Certificates", toJSON @[Certificate] mempty)
         ]
         $ \(key, upd) -> do
           let operation = Operation Replace (Just (NormalPath (AttrPath Nothing key Nothing))) (Just upd)
           let patchOp = PatchOp [operation]
           User.applyPatch user patchOp `shouldSatisfy` isLeft
+
     it "applies patch to `extra`" $ do
       let schemas' = []
       let extras = KeyMap.empty
@@ -150,33 +168,244 @@ spec = do
       let operation = Operation Replace (Just programmingLanguagePath) (Just (toJSON @Text "haskell"))
       let patchOp = PatchOp [operation]
       User.extra <$> User.applyPatch user patchOp `shouldBe` Right (KeyMap.singleton "programmingLanguage" "haskell")
+
+  describe "applyPatch (emails value-path)" $ do
+    let mkEmail typ' raw = case validate raw of
+          Right a -> Email.Email (Just typ') (Email.EmailAddress a) Nothing
+          Left _ -> error $ "invalid email in test: " <> show raw
+        mkEmailPrimary typ' raw b = case validate raw of
+          Right a -> Email.Email (Just typ') (Email.EmailAddress a) (Just (ScimBool b))
+          Left _ -> error $ "invalid email in test: " <> show raw
+        mkUser :: [Email.Email] -> User PatchTag
+        mkUser es = (User.empty [] "hello" KeyMap.empty :: User PatchTag) {emails = es}
+        emailTypePath = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].type"
+        emailPrimaryPath = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].primary"
+        emailValuePath = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].value"
+    it "creates a work email via Replace when none matches" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) (Just (String "x@y.com"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "x@y.com"]
+    it "updates an existing matching email's value" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) (Just (String "new@example.com"))
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      length (emails patched) `shouldBe` 1
+      emails patched `shouldBe` [mkEmail "work" "new@example.com"]
+    it "fails when no value is provided" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) Nothing
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "removes the whole matching email entry" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com", mkEmail "home" "h@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "home" "h@example.com"]
+    it "is case-insensitive in the path" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "EMAILS[TYPE EQ \"work\"].VALUE"
+          operation = Operation Replace (Just p) (Just (String "ci@example.com"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "ci@example.com"]
+    it "still rejects unsupported multi-valued attributes" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "phoneNumbers[type eq \"x\"].value"
+          operation = Operation Replace (Just p) (Just (String "+15555550100"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "updates the 'type' sub-attribute of matching emails" $ do
+      let Right p = emailTypePath
+          operation = Operation Replace (Just p) (Just (String "custom"))
+          result = User.applyPatch (mkUser [mkEmail "work" "a@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "custom" "a@example.com"]
+    it "updates the 'primary' sub-attribute of matching emails" $ do
+      let Right p = emailPrimaryPath
+          operation = Operation Replace (Just p) (Just (Bool True))
+          result = User.applyPatch (mkUser [mkEmail "work" "a@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmailPrimary "work" "a@example.com" True]
+    it "replaces a whole matching email entry from an object value" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          newVal = object ["value" .= String "new@example.com", "type" .= String "work"]
+          operation = Operation Replace (Just p) (Just newVal)
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "new@example.com"]
+    it "replaces a whole matching email entry from an array value" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          newVal = toJSON [object ["value" .= String "arr@example.com", "type" .= String "work"]]
+          operation = Operation Replace (Just p) (Just newVal)
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "arr@example.com"]
+    it "Add on .value updates an existing matching email" $ do
+      let Right p = emailValuePath
+          operation = Operation Add (Just p) (Just (String "new@example.com"))
+          result = User.applyPatch (mkUser [mkEmail "work" "old@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "new@example.com"]
+    it "Add on .value creates a work email when none matches" $ do
+      let Right p = emailValuePath
+          operation = Operation Add (Just p) (Just (String "x@y.com"))
+          result = User.applyPatch (mkUser []) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "x@y.com"]
+    it "Add on a whole emails entry appends without overwriting" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"]"
+          newVal = object ["value" .= String "added@example.com", "type" .= String "work"]
+          operation = Operation Add (Just p) (Just newVal)
+          result = User.applyPatch (mkUser [mkEmail "work" "keep@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "keep@example.com", mkEmail "work" "added@example.com"]
+    it "removes the 'type' sub-attribute, keeping the entry and 'primary'" $ do
+      let Right p = emailTypePath
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+          Right addr = validate "w@example.com"
+      emails patched `shouldBe` [Email.Email Nothing (Email.EmailAddress addr) (Just (ScimBool True))]
+    it "removes the 'primary' sub-attribute, keeping the entry and 'type'" $ do
+      let Right p = emailPrimaryPath
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "w@example.com"]
+    it "rejects removing the 'value' sub-attribute" $ do
+      let Right p = emailValuePath
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "unassigns 'type' on an explicit null value (RFC 7643 §2.5)" $ do
+      let Right p = emailTypePath
+          operation = Operation Replace (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+          Right addr = validate "w@example.com"
+      emails patched `shouldBe` [Email.Email Nothing (Email.EmailAddress addr) (Just (ScimBool True))]
+    it "unassigns 'primary' on an explicit null value (RFC 7643 §2.5)" $ do
+      let Right p = emailPrimaryPath
+          operation = Operation Replace (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` [mkEmail "work" "w@example.com"]
+    it "rejects an explicit null value on the 'value' sub-attribute" $ do
+      let Right p = emailValuePath
+          operation = Operation Replace (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "removes all emails on a filterless remove (RFC 7644 §3.5.2.2)" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com", mkEmail "home" "h@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      emails patched `shouldBe` []
+    it "Add with an explicit null value unassigns 'type' like Replace" $ do
+      let Right p = emailTypePath
+          operation = Operation Add (Just p) (Just Null)
+          result = User.applyPatch (mkUser [mkEmailPrimary "work" "w@example.com" True]) (PatchOp [operation])
+      result `shouldSatisfy` isRight
+      let Right patched = result
+          Right addr = validate "w@example.com"
+      emails patched `shouldBe` [Email.Email Nothing (Email.EmailAddress addr) (Just (ScimBool True))]
+    it "rejects removing an unknown emails sub-attribute" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails[type eq \"work\"].displayname"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "rejects removing an emails sub-attribute without a filter" $ do
+      let Right p = PatchOp.parsePath (User.supportedSchemas @PatchTag) "emails.type"
+          operation = Operation Remove (Just p) Nothing
+          result = User.applyPatch (mkUser [mkEmail "work" "w@example.com"]) (PatchOp [operation])
+      result `shouldSatisfy` isLeft
+    it "unassigns a Maybe-typed attribute on an explicit null value (RFC 7643 §2.5)" $ do
+      let setPath = Just (NormalPath (AttrPath Nothing "displayname" Nothing))
+          result =
+            User.applyPatch (mkUser []) . PatchOp $
+              [ Operation Replace setPath (Just (String "Old")),
+                Operation Replace setPath (Just Null)
+              ]
+      result `shouldSatisfy` isRight
+      let Right patched = result
+      displayName patched `shouldBe` Nothing
   describe "JSON serialization" $ do
     it "handles all fields" $ do
       require prop_roundtrip
       toJSON completeUser `shouldBe` completeUserJson
       eitherDecode (encode completeUserJson) `shouldBe` Right completeUser
+
     it "has defaults for all optional and multi-valued fields" $ do
       toJSON minimalUser `shouldBe` minimalUserJson
       eitherDecode (encode minimalUserJson) `shouldBe` Right minimalUser
-    it "treats 'null' and '[]' as absence of fields" $
+
+    it "treats 'null' and '[]' as absence of fields" $ do
       eitherDecode (encode minimalUserJsonRedundant)
         `shouldBe` Right minimalUser
+
     it "allows casing variations in field names" $ do
       require $ mk_prop_caseInsensitive genUser
       require $ mk_prop_caseInsensitive (ListResponse.fromList . (: []) <$> genStoredUser)
       eitherDecode (encode minimalUserJsonNonCanonical) `shouldBe` Right minimalUser
-    it "doesn't require the 'schemas' field" $
+
+    it "doesn't require the 'schemas' field" $ do
       eitherDecode (encode minimalUserJsonNoSchemas)
         `shouldBe` Right minimalUser
+
     it "doesn't add 'extra' if it's an empty object" $ do
       toJSON (extendedUser UserExtraEmpty) `shouldBe` extendedUserEmptyJson
       eitherDecode (encode extendedUserEmptyJson)
         `shouldBe` Right (extendedUser UserExtraEmpty)
+
     it "encodes and decodes 'extra' correctly" $ do
       toJSON (extendedUser (UserExtraObject "foo")) `shouldBe` extendedUserObjectJson
       eitherDecode (encode extendedUserObjectJson)
         `shouldBe` Right (extendedUser (UserExtraObject "foo"))
 
+  describe "roles (RFC 7643 complex multi-valued attribute)" $ do
+    it "renders as objects with a 'value' sub-attribute, not bare strings" $ do
+      toJSON (Role (Just "member") Nothing Nothing Nothing)
+        `shouldBe` [scim| {"value": "member"} |]
+
+    it "parses the RFC-compliant object form" $ do
+      eitherDecode "{\"value\":\"member\"}"
+        `shouldBe` Right (Role (Just "member") Nothing Nothing Nothing)
+
+    it "still parses the legacy bare-string form for backwards compatibility" $ do
+      eitherDecode "\"member\""
+        `shouldBe` Right (Role (Just "member") Nothing Nothing Nothing)
+
+  describe "entitlements (RFC 7643 complex multi-valued attribute)" $ do
+    it "renders as objects with a 'value' sub-attribute, not bare strings" $ do
+      toJSON (Entitlement (Just "some entitlement") Nothing Nothing Nothing)
+        `shouldBe` [scim| {"value": "some entitlement"} |]
+
+    it "parses the RFC-compliant object form" $ do
+      eitherDecode "{\"value\":\"some entitlement\"}"
+        `shouldBe` Right (Entitlement (Just "some entitlement") Nothing Nothing Nothing)
+
+    it "still parses the legacy bare-string form for backwards compatibility" $ do
+      eitherDecode "\"some entitlement\""
+        `shouldBe` Right (Entitlement (Just "some entitlement") Nothing Nothing Nothing)
+
 genName :: Gen Name
 genName =
   Name
@@ -320,8 +549,22 @@ completeUser =
               Address.primary = Just (ScimBool True)
             }
         ],
-      entitlements = ["sample entitlement"],
-      roles = ["sample role"],
+      entitlements =
+        [ Entitlement
+            { Entitlement.value = Just "sample entitlement",
+              Entitlement.typ = Nothing,
+              Entitlement.display = Nothing,
+              Entitlement.primary = Nothing
+            }
+        ],
+      roles =
+        [ Role
+            { Role.value = Just "sample role",
+              Role.typ = Nothing,
+              Role.display = Nothing,
+              Role.primary = Nothing
+            }
+        ],
       x509Certificates =
         [ Certificate
             { Certificate.typ = Just "sample certificate type",
@@ -337,7 +580,9 @@ completeUserJson =
   [scim|
 {
   "roles": [
-    "sample role"
+    {
+      "value": "sample role"
+    }
   ],
   "x509Certificates": [
     {
@@ -388,7 +633,9 @@ completeUserJson =
   ],
   "preferredLanguage": "da, en-gb;q=0.8, en;q=0.7",
   "entitlements": [
-    "sample entitlement"
+    {
+      "value": "sample entitlement"
+    }
   ],
   "displayName": "sample displayName",
   "nickName": "sample nickName",
diff --git a/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs b/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs
index eab238e7b6d..0ac5f015d25 100644
--- a/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs
+++ b/libs/saml2-web-sso/src/SAML2/WebSSO/Test/Util/TestSP.hs
@@ -35,6 +35,7 @@ import Data.Time
 import Data.UUID qualified as UUID
 import Data.UUID.V4 qualified as UUID
 import Data.Void (Void)
+import Data.X509 qualified as X509
 import SAML2.WebSSO as SAML
 import SAML2.WebSSO.API.Example (GetAllIdPs (..), RequestStore, simpleGetIdPConfigBy, simpleGetIdpIssuer', simpleIsAliveID', simpleStoreID', simpleStoreRequest', simpleUnStoreID', simpleUnStoreRequest')
 import SAML2.WebSSO.Test.Util.Types
@@ -212,12 +213,18 @@ makeTestIdPConfig = do
   pure (IdPConfig {..}, sampleIdP)
 
 makeSampleIdPMetadata :: (HasCallStack) => (MonadIO m, MonadRandom m) => m SampleIdP
-makeSampleIdPMetadata = do
+makeSampleIdPMetadata = SAML.mkSignCredsWithCert Nothing 96 >>= makeSampleIdPMetadataWithCert
+
+makeSampleIdPMetadataWithCert ::
+  (HasCallStack) =>
+  (MonadIO m, MonadRandom m) =>
+  (SignPrivCreds, SignCreds, X509.SignedCertificate) ->
+  m SampleIdP
+makeSampleIdPMetadataWithCert (privcreds, creds, cert) = do
   issuer <- makeIssuer
   requri <- do
     uuid <- UUID.toASCIIBytes <$> liftIO UUID.nextRandom
     pure $ [uri|https://requri.net/|] & pathL .~ ("/" <> uuid)
-  (privcreds, creds, cert) <- SAML.mkSignCredsWithCert Nothing 96
   pure $ SampleIdP (IdPMetadata issuer requri (NonEmpty.singleton cert)) privcreds creds cert
 
 makeIssuer :: (MonadIO m) => m Issuer
diff --git a/libs/types-common/src/Data/Domain.hs b/libs/types-common/src/Data/Domain.hs
index 4b21945919d..6d51e9f844d 100644
--- a/libs/types-common/src/Data/Domain.hs
+++ b/libs/types-common/src/Data/Domain.hs
@@ -132,6 +132,9 @@ instance Arbitrary Domain where
   arbitrary =
     either (error . ("arbitrary @Domain: " <>)) id . mkDomain . getDomainText <$> arbitrary
 
+instance QC.CoArbitrary Domain where
+  coarbitrary d = QC.coarbitrary (Text.unpack (domainText d))
+
 -- | only for QuickCheck
 newtype DomainText = DomainText {getDomainText :: Text}
   deriving (Eq, Show)
diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs
index a1c0aedf911..d9d7156640e 100644
--- a/libs/types-common/src/Data/Id.hs
+++ b/libs/types-common/src/Data/Id.hs
@@ -41,6 +41,7 @@ module Data.Id
     parseIdFromText,
     idToText,
     idToString,
+    invitationIdToUserId,
     idObjectSchema,
     IdObject (..),
 
@@ -102,6 +103,10 @@ import System.Logger (ToBytes)
 import Test.QuickCheck
 import Test.QuickCheck.Instances ()
 
+-- | Pending invitation users reuse the invitation UUID as the user UUID.
+invitationIdToUserId :: InvitationId -> UserId
+invitationIdToUserId = Id . toUUID
+
 data IdTag
   = Asset
   | Conversation
diff --git a/libs/types-common/src/Data/Misc.hs b/libs/types-common/src/Data/Misc.hs
index 8f0129a62a9..63f949f4b2d 100644
--- a/libs/types-common/src/Data/Misc.hs
+++ b/libs/types-common/src/Data/Misc.hs
@@ -50,6 +50,7 @@ module Data.Misc
     mkDurationLiteral,
     unsafeDurationLiteral,
     durationToMicros,
+    durationToCeilingSeconds,
 
     -- * HttpsUrl
     HttpsUrl (..),
@@ -330,6 +331,17 @@ durationToMicros :: Duration -> Int
 durationToMicros =
   fromInteger . flip div 1_000_000 . diffTimeToPicoseconds . duration
 
+-- | Convert a 'Duration' to whole seconds, rounding up and clamping a bounded integral type.
+-- Non-positive durations map to zero.
+durationToCeilingSeconds :: forall a. (Bounded a, Integral a) => Duration -> a
+durationToCeilingSeconds d
+  | duration d <= 0 = 0
+  | otherwise =
+      fromInteger $
+        min
+          (toInteger (maxBound :: a))
+          (ceiling (duration d) :: Integer)
+
 instance FromJSON Duration where
   parseJSON = withText "Duration" $ either fail pure . parseDuration
 
diff --git a/libs/types-common/src/Data/Range.hs b/libs/types-common/src/Data/Range.hs
index ed74973b2e7..9a3794ca2f2 100644
--- a/libs/types-common/src/Data/Range.hs
+++ b/libs/types-common/src/Data/Range.hs
@@ -507,7 +507,11 @@ genRangeText ::
   (KnownNat n, KnownNat m, n <= m) =>
   Gen Char ->
   Gen (Range n m Text)
-genRangeText = genRange fromString
+genRangeText genChar = genRange fromString (QC.suchThat genChar isUnicodeScalarValue)
+  where
+    isUnicodeScalarValue c =
+      let codePoint = ord c
+       in codePoint < 0xD800 || codePoint > 0xDFFF
 
 instance
   (AsciiChars c, KnownNat n, KnownNat m, n <= m, Arbitrary (AsciiChar c)) =>
diff --git a/libs/types-common/src/Data/Secret.hs b/libs/types-common/src/Data/Secret.hs
new file mode 100644
index 00000000000..73df5920d22
--- /dev/null
+++ b/libs/types-common/src/Data/Secret.hs
@@ -0,0 +1,43 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option)
+-- any later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Data.Secret
+  ( SecretText,
+    secretText,
+    revealSecretText,
+  )
+where
+
+import Imports
+
+-- | Text that may contain credentials or other sensitive material.
+--
+-- The constructor is intentionally opaque. 'revealSecretText' should only be
+-- used at the narrow boundary where an external API requires the plaintext
+-- representation.
+newtype SecretText = SecretText Text
+
+instance Show SecretText where
+  show _ = ""
+
+-- | Wrap sensitive text without exposing it through the public constructor.
+secretText :: Text -> SecretText
+secretText = SecretText
+
+-- | Reveal sensitive text for an API that explicitly requires plaintext.
+revealSecretText :: SecretText -> Text
+revealSecretText (SecretText value) = value
diff --git a/libs/types-common/test/Main.hs b/libs/types-common/test/Main.hs
index 4814492dfd0..41bd84e1939 100644
--- a/libs/types-common/test/Main.hs
+++ b/libs/types-common/test/Main.hs
@@ -23,6 +23,7 @@ where
 import Imports
 import Test.Data.Mailbox qualified as Mailbox
 import Test.Data.PEMKeys qualified as PEMKeys
+import Test.Data.Secret qualified as Secret
 import Test.Domain qualified as Domain
 import Test.Handle qualified as Handle
 import Test.Properties qualified as Properties
@@ -41,5 +42,6 @@ main =
         Handle.tests,
         Qualified.tests,
         PEMKeys.tests,
-        Mailbox.tests
+        Mailbox.tests,
+        Secret.tests
       ]
diff --git a/libs/types-common/test/Test/Data/Secret.hs b/libs/types-common/test/Test/Data/Secret.hs
new file mode 100644
index 00000000000..65ab82c7cdc
--- /dev/null
+++ b/libs/types-common/test/Test/Data/Secret.hs
@@ -0,0 +1,31 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option)
+-- any later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Test.Data.Secret (tests) where
+
+import Data.Secret
+import Imports
+import Test.Tasty
+import Test.Tasty.HUnit
+
+tests :: TestTree
+tests =
+  testGroup
+    "SecretText"
+    [ testCase "does not expose its value through Show" $
+        show (secretText "database-password") @?= ""
+    ]
diff --git a/libs/types-common/types-common.cabal b/libs/types-common/types-common.cabal
index e2249067182..7d1590c0571 100644
--- a/libs/types-common/types-common.cabal
+++ b/libs/types-common/types-common.cabal
@@ -31,6 +31,7 @@ library
     Data.Qualified
     Data.Range
     Data.RetryAfter
+    Data.Secret
     Data.SizedHashMap
     Data.Text.Ascii
     Data.UUID.Tagged
@@ -160,6 +161,7 @@ test-suite types-common-tests
     Paths_types_common
     Test.Data.Mailbox
     Test.Data.PEMKeys
+    Test.Data.Secret
     Test.Domain
     Test.Handle
     Test.Properties
diff --git a/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs b/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs
index 45f43e1cefe..fa30cb1e3bd 100644
--- a/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs
+++ b/libs/wai-utilities/src/Network/Wai/Utilities/Exception.hs
@@ -1,3 +1,20 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
 module Network.Wai.Utilities.Exception where
 
 import Control.Exception
diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix
index 5671728c638..cb9e759f8f9 100644
--- a/libs/wire-api/default.nix
+++ b/libs/wire-api/default.nix
@@ -8,6 +8,7 @@
 , aeson-pretty
 , aeson-qq
 , amqp
+, arbiter-core
 , async
 , attoparsec
 , barbies
@@ -40,6 +41,7 @@
 , errors
 , extended
 , extra
+, file-embed
 , filepath
 , generics-sop
 , ghc-prim
@@ -105,12 +107,14 @@
 , tasty-hspec
 , tasty-hunit
 , tasty-quickcheck
+, template-haskell
 , text
 , these
 , time
 , tinylog
 , transformers
 , types-common
+, tz
 , unliftio
 , unordered-containers
 , uri-bytestring
@@ -123,6 +127,7 @@
 , wai-websockets
 , websockets
 , wire-message-proto-lens
+, yaml
 , zauth
 }:
 mkDerivation {
@@ -132,6 +137,7 @@ mkDerivation {
   libraryHaskellDepends = [
     aeson
     amqp
+    arbiter-core
     attoparsec
     barbies
     base
@@ -223,6 +229,7 @@ mkDerivation {
     tinylog
     transformers
     types-common
+    tz
     unordered-containers
     uri-bytestring
     utf8-string
@@ -252,6 +259,8 @@ mkDerivation {
     crypton
     crypton-pem
     currency-codes
+    data-default
+    file-embed
     filepath
     hex
     hspec
@@ -269,6 +278,7 @@ mkDerivation {
     QuickCheck
     ram
     random
+    regex-tdfa
     saml2-web-sso
     schema-profunctor
     servant
@@ -278,6 +288,7 @@ mkDerivation {
     tasty-hspec
     tasty-hunit
     tasty-quickcheck
+    template-haskell
     text
     time
     types-common
@@ -287,6 +298,7 @@ mkDerivation {
     vector
     wai
     wire-message-proto-lens
+    yaml
   ];
   license = lib.meta.getLicenseFromSpdxId "AGPL-3.0-only";
 }
diff --git a/libs/wire-api/src/Wire/API/BackgroundJobs.hs b/libs/wire-api/src/Wire/API/BackgroundJobs.hs
index 78f179ed955..b96f2cd7505 100644
--- a/libs/wire-api/src/Wire/API/BackgroundJobs.hs
+++ b/libs/wire-api/src/Wire/API/BackgroundJobs.hs
@@ -32,39 +32,39 @@ import Network.AMQP qualified as Q
 import Network.AMQP.Types qualified as QT
 import Wire.Arbitrary (Arbitrary (..), GenericUniform (..))
 
-data JobPayload
-  = JobSyncUserGroupAndChannel SyncUserGroupAndChannel
-  | JobSyncUserGroup SyncUserGroup
+data BackgroundJobPayload
+  = BackgroundJobSyncUserGroupAndChannel SyncUserGroupAndChannel
+  | BackgroundJobSyncUserGroup SyncUserGroup
   deriving stock (Eq, Show, Generic)
-  deriving (Arbitrary) via GenericUniform JobPayload
+  deriving (Arbitrary) via GenericUniform BackgroundJobPayload
 
-jobPayloadLabel :: JobPayload -> Text
-jobPayloadLabel p = case jobPayloadTag p of
-  JobSyncUserGroupAndChannelTag -> "sync-user-group-and-channel"
-  JobSyncUserGroupTag -> "sync-user-group"
+backgroundJobPayloadLabel :: BackgroundJobPayload -> Text
+backgroundJobPayloadLabel p = case backgroundJobPayloadTag p of
+  BackgroundJobSyncUserGroupAndChannelTag -> "sync-user-group-and-channel"
+  BackgroundJobSyncUserGroupTag -> "sync-user-group"
 
-data JobPayloadTag
-  = JobSyncUserGroupAndChannelTag
-  | JobSyncUserGroupTag
+data BackgroundJobPayloadTag
+  = BackgroundJobSyncUserGroupAndChannelTag
+  | BackgroundJobSyncUserGroupTag
   deriving stock (Eq, Ord, Bounded, Enum, Show, Generic)
-  deriving (Arbitrary) via GenericUniform JobPayloadTag
+  deriving (Arbitrary) via GenericUniform BackgroundJobPayloadTag
 
-instance ToSchema JobPayloadTag where
+instance ToSchema BackgroundJobPayloadTag where
   schema =
     enum @Text $
       mconcat
-        [ element "sync-user-group-and-channel" JobSyncUserGroupAndChannelTag,
-          element "sync-user-group" JobSyncUserGroupTag
+        [ element "sync-user-group-and-channel" BackgroundJobSyncUserGroupAndChannelTag,
+          element "sync-user-group" BackgroundJobSyncUserGroupTag
         ]
 
-jobPayloadTag :: JobPayload -> JobPayloadTag
-jobPayloadTag =
+backgroundJobPayloadTag :: BackgroundJobPayload -> BackgroundJobPayloadTag
+backgroundJobPayloadTag =
   \case
-    JobSyncUserGroupAndChannel {} -> JobSyncUserGroupAndChannelTag
-    JobSyncUserGroup {} -> JobSyncUserGroupTag
+    BackgroundJobSyncUserGroupAndChannel {} -> BackgroundJobSyncUserGroupAndChannelTag
+    BackgroundJobSyncUserGroup {} -> BackgroundJobSyncUserGroupTag
 
-jobPayloadTagSchema :: ObjectSchema SwaggerDoc JobPayloadTag
-jobPayloadTagSchema = field "type" schema
+backgroundJobPayloadTagSchema :: ObjectSchema SwaggerDoc BackgroundJobPayloadTag
+backgroundJobPayloadTagSchema = field "type" schema
 
 data SyncUserGroupAndChannel = SyncUserGroupAndChannel
   { teamId :: TeamId,
@@ -102,44 +102,44 @@ instance ToSchema SyncUserGroup where
         <*> (.userGroupId) .= field "user_group_id" schema
         <*> (.actor) .= maybe_ (optField "actor" schema)
 
-makePrisms ''JobPayload
+makePrisms ''BackgroundJobPayload
 
-jobPayloadObjectSchema :: ObjectSchema SwaggerDoc JobPayload
-jobPayloadObjectSchema =
+backgroundJobPayloadObjectSchema :: ObjectSchema SwaggerDoc BackgroundJobPayload
+backgroundJobPayloadObjectSchema =
   snd
-    <$> (jobPayloadTag &&& id)
+    <$> (backgroundJobPayloadTag &&& id)
       .= bind
-        (fst .= jobPayloadTagSchema)
-        (snd .= dispatch jobPayloadDataSchema)
+        (fst .= backgroundJobPayloadTagSchema)
+        (snd .= dispatch backgroundJobPayloadDataSchema)
   where
-    jobPayloadDataSchema :: JobPayloadTag -> ObjectSchema SwaggerDoc JobPayload
-    jobPayloadDataSchema = \case
-      JobSyncUserGroupAndChannelTag -> tag _JobSyncUserGroupAndChannel (field "payload" schema)
-      JobSyncUserGroupTag -> tag _JobSyncUserGroup (field "payload" schema)
+    backgroundJobPayloadDataSchema :: BackgroundJobPayloadTag -> ObjectSchema SwaggerDoc BackgroundJobPayload
+    backgroundJobPayloadDataSchema = \case
+      BackgroundJobSyncUserGroupAndChannelTag -> tag _BackgroundJobSyncUserGroupAndChannel (field "payload" schema)
+      BackgroundJobSyncUserGroupTag -> tag _BackgroundJobSyncUserGroup (field "payload" schema)
 
-instance ToSchema JobPayload where
-  schema = object jobPayloadObjectSchema
+instance ToSchema BackgroundJobPayload where
+  schema = object backgroundJobPayloadObjectSchema
 
-deriving via (Schema JobPayload) instance Aeson.FromJSON JobPayload
+deriving via (Schema BackgroundJobPayload) instance Aeson.FromJSON BackgroundJobPayload
 
-deriving via (Schema JobPayload) instance Aeson.ToJSON JobPayload
+deriving via (Schema BackgroundJobPayload) instance Aeson.ToJSON BackgroundJobPayload
 
-deriving via (Schema JobPayload) instance S.ToSchema JobPayload
+deriving via (Schema BackgroundJobPayload) instance S.ToSchema BackgroundJobPayload
 
 -- | Background job envelope. Payload is a free-form JSON object.
-data Job = Job
+data BackgroundJob = BackgroundJob
   { jobId :: JobId,
     requestId :: RequestId,
-    payload :: JobPayload
+    payload :: BackgroundJobPayload
   }
   deriving stock (Eq, Show, Generic)
-  deriving (Arbitrary) via GenericUniform Job
-  deriving (Aeson.ToJSON, Aeson.FromJSON, S.ToSchema) via Schema Job
+  deriving (Arbitrary) via GenericUniform BackgroundJob
+  deriving (Aeson.ToJSON, Aeson.FromJSON, S.ToSchema) via Schema BackgroundJob
 
-instance ToSchema Job where
+instance ToSchema BackgroundJob where
   schema =
     object $
-      Job
+      BackgroundJob
         <$> jobId .= field "id" schema
         <*> requestId .= field "requestId" schema
         <*> payload .= field "payload" schema
diff --git a/libs/wire-api/src/Wire/API/Error/Galley.hs b/libs/wire-api/src/Wire/API/Error/Galley.hs
index 47e020fca0b..895bae66914 100644
--- a/libs/wire-api/src/Wire/API/Error/Galley.hs
+++ b/libs/wire-api/src/Wire/API/Error/Galley.hs
@@ -50,6 +50,8 @@ module Wire.API.Error.Galley
     GroupInfoDiagnostics (..),
     MLSOutOfSyncError (..),
     AdminlessConversation (..),
+    MeetingError (..),
+    InvalidTimesReason (..),
   )
 where
 
@@ -394,6 +396,52 @@ type instance MapError 'MeetingNotFound = 'StaticError 404 "meeting-not-found" "
 
 type instance MapError 'CodeStoreNotFound = 'StaticError 404 "code-store-not-found" "Code store not found"
 
+-- | Errors raised by the meetings subsystem, exposed at the API level via
+-- @CanThrow 'MeetingError'@. Every constructor keeps the @invalid-op@ label
+-- but carries an explicit message; 'InvalidTimes' additionally records which
+-- validation failed.
+data MeetingError
+  = InvalidTimes InvalidTimesReason
+  | EmptyUpdate
+  | MeetingsFeatureDisabled
+  deriving stock (Eq, Show)
+
+-- | The individual failure modes of meeting time validation.
+data InvalidTimesReason
+  = -- | The (effective) end time is not after the start time.
+    EndBeforeStart
+  | -- | The start time of a new meeting is too far in the past.
+    StartTimeTooFarInPast
+  | -- | An updated time is further in the past than the past-edit window allows.
+    TimesBeyondPastEditWindow
+  deriving stock (Eq, Show)
+
+meetingErrorToDyn :: MeetingError -> DynError
+meetingErrorToDyn = \case
+  InvalidTimes EndBeforeStart ->
+    DynError 403 "invalid-op" "Meeting end time must be after the start time"
+  InvalidTimes StartTimeTooFarInPast ->
+    DynError 403 "invalid-op" "Meeting start time is too far in the past"
+  InvalidTimes TimesBeyondPastEditWindow ->
+    DynError 403 "invalid-op" "Meeting times must not be set further into the past than the past-edit window allows"
+  EmptyUpdate -> DynError 403 "invalid-op" "Meeting update must set at least one field"
+  MeetingsFeatureDisabled -> DynError 403 "invalid-op" "The meetings feature is not enabled"
+
+type instance ErrorEffect MeetingError = Error MeetingError
+
+instance APIError MeetingError where
+  toResponse = toResponse . meetingErrorToDyn
+
+-- | The swagger documentation for 'MeetingError': one representative static
+-- error (the runtime messages are more detailed but share code and label).
+type MeetingErrorStatic = 'StaticError 403 "invalid-op" "Invalid meeting times, empty update, or meetings feature disabled"
+
+instance IsSwaggerError MeetingError where
+  addToOpenApi = addStaticErrorToSwagger @MeetingErrorStatic
+
+instance (Member (Error DynError) r) => ServerEffect (Error MeetingError) r where
+  interpretServerEffect = mapError meetingErrorToDyn
+
 --------------------------------------------------------------------------------
 -- Team Member errors
 
diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs
index a75477d2ade..f39a745fcf8 100644
--- a/libs/wire-api/src/Wire/API/Event/Conversation.hs
+++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs
@@ -28,9 +28,15 @@ module Wire.API.Event.Conversation
     EventVia (..),
     EventFrom (..),
     eventFromUserId,
+    mkEventFrom,
+    eventVia,
     AddCodeResult (..),
     createConversationEventData,
     isCellsConversationEvent,
+    SystemEvent (..),
+    SystemEventType (..),
+    SystemEventData (..),
+    systemEventDataType,
 
     -- * Cells Event
     CellsEvent (..),
@@ -46,6 +52,7 @@ module Wire.API.Event.Conversation
     _EdConvReceiptModeUpdate,
     _EdConvRename,
     _EdConvDelete,
+    _EdConvDeleteMeeting,
     _EdConvAccessUpdate,
     _EdConvMessageTimerUpdate,
     _EdConvCodeUpdate,
@@ -58,6 +65,9 @@ module Wire.API.Event.Conversation
     _EdMLSMessage,
     _EdMLSWelcome,
     _EdAddPermissionUpdate,
+    _EdSystemConvDelete,
+    _EdSystemMemberUpdate,
+    _EdSystemAdminlessReminder,
 
     -- * Event data helpers
     SimpleMember (..),
@@ -68,6 +78,7 @@ module Wire.API.Event.Conversation
     MemberUpdateData (..),
     OtrMessage (..),
     ConversationReset (..),
+    AdminlessReminder (..),
 
     -- * re-exports
     ConversationReceiptModeUpdate (..),
@@ -185,6 +196,7 @@ data EventType
   | ConvCreateMeeting
   | ConvConnect
   | ConvDelete
+  | ConvDeleteMeeting
   | ConvReset
   | ConvReceiptModeUpdate
   | OtrMessageAdd
@@ -194,6 +206,7 @@ data EventType
   | ProtocolUpdate
   | AddPermissionUpdate
   | ConvHistoryUpdate
+  | ConvAdminlessReminder
   deriving stock (Eq, Show, Generic, Enum, Bounded, Ord)
   deriving (Arbitrary) via (GenericUniform EventType)
   deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType
@@ -214,6 +227,7 @@ instance ToSchema EventType where
           element "conversation.create" ConvCreate,
           element "conversation.create-meeting" ConvCreateMeeting,
           element "conversation.delete" ConvDelete,
+          element "conversation.delete-meeting" ConvDeleteMeeting,
           element "conversation.mls-reset" ConvReset,
           element "conversation.connect-request" ConvConnect,
           element "conversation.typing" Typing,
@@ -222,7 +236,8 @@ instance ToSchema EventType where
           element "conversation.mls-welcome" MLSWelcome,
           element "conversation.protocol-update" ProtocolUpdate,
           element "conversation.add-permission-update" AddPermissionUpdate,
-          element "conversation.history-update" ConvHistoryUpdate
+          element "conversation.history-update" ConvHistoryUpdate,
+          element "conversation.adminless-reminder" ConvAdminlessReminder
         ]
 
 data EventData
@@ -232,6 +247,7 @@ data EventData
   | EdConvReceiptModeUpdate ConversationReceiptModeUpdate
   | EdConvRename ConversationRename
   | EdConvDelete
+  | EdConvDeleteMeeting
   | EdConvReset ConversationReset
   | EdConvAccessUpdate ConversationAccessData
   | EdConvMessageTimerUpdate ConversationMessageTimerUpdate
@@ -247,6 +263,7 @@ data EventData
   | EdProtocolUpdate P.ProtocolTag
   | EdAddPermissionUpdate Conv.AddPermissionUpdate
   | EdConvHistoryUpdate History
+  | EdAdminlessReminder AdminlessReminder
   deriving stock (Eq, Show, Generic)
 
 genEventData :: EventType -> QC.Gen EventData
@@ -268,10 +285,12 @@ genEventData = \case
   MLSMessageAdd -> EdMLSMessage <$> arbitrary
   MLSWelcome -> EdMLSWelcome <$> arbitrary
   ConvDelete -> pure EdConvDelete
+  ConvDeleteMeeting -> pure EdConvDeleteMeeting
   ConvReset -> EdConvReset <$> arbitrary
   ProtocolUpdate -> EdProtocolUpdate <$> arbitrary
   AddPermissionUpdate -> EdAddPermissionUpdate <$> arbitrary
   ConvHistoryUpdate -> EdConvHistoryUpdate <$> arbitrary
+  ConvAdminlessReminder -> EdAdminlessReminder <$> arbitrary
 
 eventDataType :: EventData -> EventType
 eventDataType (EdMembersJoin _) = MemberJoin
@@ -291,10 +310,12 @@ eventDataType (EdOtrMessage _) = OtrMessageAdd
 eventDataType (EdMLSMessage _) = MLSMessageAdd
 eventDataType (EdMLSWelcome _) = MLSWelcome
 eventDataType EdConvDelete = ConvDelete
+eventDataType EdConvDeleteMeeting = ConvDeleteMeeting
 eventDataType (EdConvReset _) = ConvReset
 eventDataType (EdProtocolUpdate _) = ProtocolUpdate
 eventDataType (EdAddPermissionUpdate _) = AddPermissionUpdate
 eventDataType (EdConvHistoryUpdate _) = ConvHistoryUpdate
+eventDataType (EdAdminlessReminder _) = ConvAdminlessReminder
 
 createConversationEventData ::
   OwnConversation GroupConvType -> EventData
@@ -312,6 +333,7 @@ isCellsConversationEvent eventType =
     ConvCreate -> True
     ConvCreateMeeting -> True
     ConvDelete -> True
+    ConvDeleteMeeting -> True
     ConvReset -> False
     ConvCodeDelete -> False
     ConvAccessUpdate -> False
@@ -326,6 +348,7 @@ isCellsConversationEvent eventType =
     ProtocolUpdate -> False
     AddPermissionUpdate -> False
     ConvHistoryUpdate -> False
+    ConvAdminlessReminder -> False
 
 --------------------------------------------------------------------------------
 -- Event data helpers
@@ -493,6 +516,111 @@ instance ToSchema ConversationReset where
         <$> (.groupId) .= field "group_id" schema
         <*> (.newGroupId) .= maybe_ (optField "new_group_id" schema)
 
+data AdminlessReminder = AdminlessReminder
+  { deletionScheduledFor :: UTCTimeMillis
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (Arbitrary) via (GenericUniform AdminlessReminder)
+  deriving (FromJSON, ToJSON, S.ToSchema) via Schema AdminlessReminder
+
+instance ToSchema AdminlessReminder where
+  schema =
+    object $
+      AdminlessReminder
+        <$> (.deletionScheduledFor) .= field "deletion_scheduled_for" schema
+
+data SystemEvent = SystemEvent
+  { seConv :: Qualified ConvId,
+    seSubConv :: Maybe SubConvId,
+    seTime :: UTCTime,
+    seTeam :: Maybe TeamId,
+    seData :: SystemEventData
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance Arbitrary SystemEvent where
+  arbitrary = do
+    SystemEvent
+      <$> arbitrary
+      <*> arbitrary
+      <*> (milli <$> arbitrary)
+      <*> arbitrary
+      <*> arbitrary
+    where
+      milli = fromUTCTimeMillis . toUTCTimeMillis
+
+data SystemEventType
+  = SystemConvDelete
+  | SystemMemberUpdate
+  | SystemAdminlessReminder
+  deriving stock (Eq, Show, Generic, Enum, Bounded, Ord)
+  deriving (Arbitrary) via (GenericUniform SystemEventType)
+  deriving (FromJSON, ToJSON, S.ToSchema) via Schema SystemEventType
+
+instance ToSchema SystemEventType where
+  schema =
+    enum @Text $
+      mconcat
+        [ element "conversation.system.delete" SystemConvDelete,
+          element "conversation.system.member-update" SystemMemberUpdate,
+          element "conversation.system.adminless-reminder" SystemAdminlessReminder
+        ]
+
+data SystemEventData
+  = EdSystemConvDelete
+  | EdSystemMemberUpdate MemberUpdateData
+  | EdSystemAdminlessReminder AdminlessReminder
+  deriving stock (Eq, Show, Generic)
+  deriving (Arbitrary) via (GenericUniform SystemEventData)
+
+systemEventDataType :: SystemEventData -> SystemEventType
+systemEventDataType EdSystemConvDelete = SystemConvDelete
+systemEventDataType (EdSystemMemberUpdate _) = SystemMemberUpdate
+systemEventDataType (EdSystemAdminlessReminder _) = SystemAdminlessReminder
+
+makePrisms ''SystemEventData
+
+taggedSystemEventDataSchema :: ObjectSchema SwaggerDoc (SystemEventType, SystemEventData)
+taggedSystemEventDataSchema =
+  bind
+    (fst .= field "type" schema)
+    (snd .= fieldOver _1 "data" edata)
+  where
+    edata :: SchemaP SwaggerDoc (A.Value, SystemEventType) A.Value SystemEventData SystemEventData
+    edata = dispatch $ \case
+      SystemConvDelete -> tag _EdSystemConvDelete null_
+      SystemMemberUpdate -> tag _EdSystemMemberUpdate (unnamed schema)
+      SystemAdminlessReminder -> tag _EdSystemAdminlessReminder (unnamed schema)
+
+instance ToSchema SystemEvent where
+  schema = object systemEventObjectSchema
+
+instance S.ToSchema SystemEvent where
+  declareNamedSchema = schemaToSwagger
+
+instance ToJSONObject SystemEvent where
+  toJSONObject =
+    KeyMap.fromList
+      . fromMaybe []
+      . schemaOut systemEventObjectSchema
+
+systemEventObjectSchema :: ObjectSchema SwaggerDoc SystemEvent
+systemEventObjectSchema =
+  mk
+    <$> (systemEventDataType . seData &&& seData) .= taggedSystemEventDataSchema
+    <* (qUnqualified . seConv) .= optional (field "conversation" schema)
+    <*> seConv .= field "qualified_conversation" schema
+    <*> seSubConv .= maybe_ (optField "subconv" schema)
+    <* const ("system" :: Text) .= field "via" (schema @Text)
+    <*> (toUTCTimeMillis . seTime) .= field "time" (fromUTCTimeMillis <$> schema)
+    <*> seTeam .= maybe_ (optField "team" schema)
+  where
+    mk (_, d) conv subconv tm team = SystemEvent conv subconv tm team d
+
+deriving via (Schema SystemEvent) instance FromJSON SystemEvent
+
+deriving via (Schema SystemEvent) instance ToJSON SystemEvent
+
 makePrisms ''EventData
 
 taggedEventDataSchema :: ObjectSchema SwaggerDoc (EventType, EventData)
@@ -525,10 +653,12 @@ taggedEventDataSchema =
       Typing -> tag _EdTyping (unnamed schema)
       ConvCodeDelete -> tag _EdConvCodeDelete null_
       ConvDelete -> tag _EdConvDelete null_
+      ConvDeleteMeeting -> tag _EdConvDeleteMeeting null_
       ConvReset -> tag _EdConvReset (unnamed schema)
       ProtocolUpdate -> tag _EdProtocolUpdate (unnamed (unProtocolUpdate <$> P.ProtocolUpdate .= schema))
       AddPermissionUpdate -> tag _EdAddPermissionUpdate (unnamed schema)
       ConvHistoryUpdate -> tag _EdConvHistoryUpdate (unnamed schema)
+      ConvAdminlessReminder -> tag _EdAdminlessReminder (unnamed schema)
 
 memberLeaveSchema :: ValueSchema NamedSwaggerDoc (EdMemberLeftReason, QualifiedUserIdList)
 memberLeaveSchema =
diff --git a/libs/wire-api/src/Wire/API/Event/Meeting.hs b/libs/wire-api/src/Wire/API/Event/Meeting.hs
new file mode 100644
index 00000000000..926059d2989
--- /dev/null
+++ b/libs/wire-api/src/Wire/API/Event/Meeting.hs
@@ -0,0 +1,138 @@
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+{-# LANGUAGE StrictData #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option) any
+-- later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+-- details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Wire.API.Event.Meeting
+  ( -- * Event
+    Event (..),
+    EventType (..),
+
+    -- * Envelope
+    EventFrom (..),
+  )
+where
+
+import Control.Applicative (optional)
+import Data.Aeson (FromJSON (..), ToJSON (..))
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Id
+import Data.Json.Util
+import Data.OpenApi qualified as S
+import Data.Qualified
+import Data.Schema
+import Data.Time (UTCTime)
+import Imports
+import Wire.API.Event.Conversation (EventFrom (..), eventFromUserId, eventVia, mkEventFrom)
+import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..))
+
+--------------------------------------------------------------------------------
+-- EventType
+
+data EventType = Create | Update | Delete | MemberAdd
+  deriving stock (Eq, Show, Generic, Enum, Bounded, Ord)
+  deriving (Arbitrary) via (GenericUniform EventType)
+  deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType
+
+instance ToSchema EventType where
+  schema =
+    enum @Text $
+      mconcat
+        [ element "meeting.create" Create,
+          element "meeting.update" Update,
+          element "meeting.delete" Delete,
+          element "meeting.member-add" MemberAdd
+        ]
+
+--------------------------------------------------------------------------------
+-- Event
+
+-- | A self-contained meeting lifecycle event. Unlike conversation events, the
+-- meeting's qualified id is carried flat at the envelope top level
+-- (@qualified_id@), with no @data@ wrapper. The 'EventFrom' envelope helper is
+-- shared with "Wire.API.Event.Conversation".
+data Event = Event
+  { evtType :: EventType,
+    evtMeeting :: Qualified MeetingId,
+    evtConv :: Qualified ConvId,
+    evtFrom :: EventFrom,
+    evtTime :: UTCTime,
+    evtTeam :: Maybe TeamId
+  }
+  deriving stock (Eq, Show, Generic)
+
+instance Arbitrary Event where
+  arbitrary =
+    Event
+      <$> arbitrary
+      <*> arbitrary
+      <*> arbitrary
+      <*> arbitrary
+      <*> (milli <$> arbitrary)
+      <*> arbitrary
+    where
+      milli = fromUTCTimeMillis . toUTCTimeMillis
+
+instance ToSchema Event where
+  schema = object eventObjectSchema
+
+eventObjectSchema :: ObjectSchema SwaggerDoc Event
+eventObjectSchema =
+  mk
+    <$> evtType .= field "type" schema
+    <*> evtMeeting .= field "qualified_id" schema
+    <* (qUnqualified . evtConv) .= optional (field "conversation" schema)
+    <*> evtConv .= field "qualified_conversation" schema
+    <* (qUnqualified . eventFromUserId . evtFrom) .= optional (field "from" schema)
+    <*> (eventFromUserId . evtFrom) .= field "qualified_from" schema
+    <*> (eventVia . evtFrom) .= field "via" schema
+    <*> (toUTCTimeMillis . evtTime) .= field "time" (fromUTCTimeMillis <$> schema)
+    <*> evtTeam .= maybe_ (optField "team" schema)
+  where
+    mk typ meeting cid uid evVia tm tid =
+      Event typ meeting cid (mkEventFrom evVia uid) tm tid
+
+instance ToJSONObject Event where
+  toJSONObject =
+    KeyMap.fromList
+      . fromMaybe []
+      . schemaOut eventObjectSchema
+
+instance FromJSON Event where
+  parseJSON = schemaParseJSON
+
+instance ToJSON Event where
+  toJSON = schemaToJSON
+
+instance S.ToSchema Event where
+  declareNamedSchema = schemaToSwagger
diff --git a/libs/wire-api/src/Wire/API/Jobs.hs b/libs/wire-api/src/Wire/API/Jobs.hs
new file mode 100644
index 00000000000..f60d60ca74f
--- /dev/null
+++ b/libs/wire-api/src/Wire/API/Jobs.hs
@@ -0,0 +1,254 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE TypeFamilies #-}
+
+-- This file is part of the Wire Server implementation.
+--
+-- Copyright (C) 2026 Wire Swiss GmbH 
+--
+-- This program is free software: you can redistribute it and/or modify it under
+-- the terms of the GNU Affero General Public License as published by the Free
+-- Software Foundation, either version 3 of the License, or (at your option)
+-- any later version.
+--
+-- This program is distributed in the hope that it will be useful, but WITHOUT
+-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License
+-- for more details.
+--
+-- You should have received a copy of the GNU Affero General Public License along
+-- with this program. If not, see .
+
+module Wire.API.Jobs where
+
+import Arbiter.Core.QueueRegistry (Queue)
+import Control.Arrow ((&&&))
+import Control.Lens (makePrisms)
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Id
+import Data.Json.Util
+import Data.OpenApi qualified as S
+import Data.Proxy
+import Data.Schema
+import Data.Text as Text
+import GHC.TypeLits
+import Imports
+import Test.QuickCheck (oneof)
+import Wire.Arbitrary (Arbitrary (..), GenericUniform (..))
+
+-- | The queue/table for jobs that operate on meetings.
+type MeetingsQueueName = "meetings"
+
+meetingsQueueName :: Text
+meetingsQueueName = Text.pack $ symbolVal (Proxy @MeetingsQueueName)
+
+-- | The queue/table for jobs that operate on conversations.
+type ConversationsQueueName = "conversations"
+
+conversationsQueueName :: Text
+conversationsQueueName = Text.pack $ symbolVal (Proxy @ConversationsQueueName)
+
+-- | Empty payload because the schedule itself carries all execution context.
+data MeetingsCleanupJob = MeetingsCleanupJob
+  deriving stock (Eq, Generic, Show)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingsCleanupJob)
+
+instance ToSchema MeetingsCleanupJob where
+  schema = object $ pure MeetingsCleanupJob
+
+instance Arbitrary MeetingsCleanupJob where
+  arbitrary = pure MeetingsCleanupJob
+
+-- | Payload for adminless deletions.
+-- Arbiter persists these payloads and workers decode them later, so changes to
+-- field names or shapes require a coordinated rollout. The origin user is
+-- optional for jobs created by system reconciliation; the request ID is always
+-- captured when a job is scheduled.
+data AdminlessDeletionJob = AdminlessDeletionJob
+  { adminlessDeletionJobTeamId :: TeamId,
+    adminlessDeletionJobConversationId :: ConvId,
+    adminlessDeletionJobOrigUserId :: Maybe UserId,
+    adminlessDeletionJobRequestId :: RequestId
+  }
+  deriving stock (Eq, Generic, Show)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessDeletionJob)
+
+instance Arbitrary AdminlessDeletionJob where
+  arbitrary = AdminlessDeletionJob <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary
+
+instance ToSchema AdminlessDeletionJob where
+  schema =
+    object $
+      AdminlessDeletionJob
+        <$> (.adminlessDeletionJobTeamId) .= field "team_id" schema
+        <*> (.adminlessDeletionJobConversationId) .= field "conversation_id" schema
+        <*> (.adminlessDeletionJobOrigUserId) .= maybe_ (optField "orig_user_id" schema)
+        <*> (.adminlessDeletionJobRequestId) .= field "request_id" schema
+
+-- | Payload for adminless reminders.
+-- Arbiter persists these payloads and workers decode them later, so changes to
+-- field names or shapes require a coordinated rollout. The origin user is
+-- optional for jobs created by system reconciliation; the request ID is always
+-- captured when a job is scheduled.
+data AdminlessReminderJob = AdminlessReminderJob
+  { adminlessReminderJobTeamId :: TeamId,
+    adminlessReminderJobConversationId :: ConvId,
+    adminlessReminderJobOrigUserId :: Maybe UserId,
+    adminlessReminderJobDeletionScheduledFor :: UTCTimeMillis,
+    adminlessReminderJobRequestId :: RequestId
+  }
+  deriving stock (Eq, Generic, Show)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessReminderJob)
+
+instance Arbitrary AdminlessReminderJob where
+  arbitrary = AdminlessReminderJob <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary
+
+instance ToSchema AdminlessReminderJob where
+  schema =
+    object $
+      AdminlessReminderJob
+        <$> (.adminlessReminderJobTeamId) .= field "team_id" schema
+        <*> (.adminlessReminderJobConversationId) .= field "conversation_id" schema
+        <*> (.adminlessReminderJobOrigUserId) .= maybe_ (optField "orig_user_id" schema)
+        <*> (.adminlessReminderJobDeletionScheduledFor) .= field "deletion_scheduled_for" schema
+        <*> (.adminlessReminderJobRequestId) .= field "request_id" schema
+
+-- | Payload for reconciling all adminless groups in a team.
+data AdminlessSetupJob = AdminlessSetupJob
+  { adminlessSetupJobTeamId :: TeamId,
+    adminlessSetupJobOrigUserId :: Maybe UserId,
+    adminlessSetupJobRequestId :: RequestId
+  }
+  deriving stock (Eq, Generic, Show)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema AdminlessSetupJob)
+
+instance Arbitrary AdminlessSetupJob where
+  arbitrary = AdminlessSetupJob <$> arbitrary <*> arbitrary <*> arbitrary
+
+instance ToSchema AdminlessSetupJob where
+  schema =
+    object $
+      AdminlessSetupJob
+        <$> (.adminlessSetupJobTeamId) .= field "team_id" schema
+        <*> (.adminlessSetupJobOrigUserId) .= maybe_ (optField "orig_user_id" schema)
+        <*> (.adminlessSetupJobRequestId) .= field "request_id" schema
+
+-- | Common representation for all queue payload envelopes.
+-- The queue-specific sum supplies the type tag and its associated data schema,
+-- while this helper guarantees the stable {"type": ..., "data": ...} shape.
+taggedJobPayloadObjectSchema ::
+  forall tag payload.
+  (Bounded tag, Enum tag, ToSchema tag) =>
+  (payload -> tag) ->
+  (tag -> ObjectSchema SwaggerDoc payload) ->
+  ObjectSchema SwaggerDoc payload
+taggedJobPayloadObjectSchema toTag toSchema =
+  snd <$> (toTag &&& id) .= bind (fst .= tagObjectSchema) (snd .= dispatch toSchema)
+  where
+    tagObjectSchema :: ObjectSchema SwaggerDoc tag
+    tagObjectSchema = field "type" schema
+
+-- | Payload persisted in the meetings queue. Keep the type tag and nested data
+-- shape stable when changing job payloads. The sum makes the queue
+-- extensible without changing its JSON envelope.
+data MeetingsJobPayload
+  = MeetingsCleanup MeetingsCleanupJob
+  deriving stock (Eq, Generic, Show)
+
+data MeetingsJobPayloadTag
+  = MeetingsCleanupTag
+  deriving stock (Eq, Ord, Bounded, Enum, Show, Generic)
+  deriving (Arbitrary) via GenericUniform MeetingsJobPayloadTag
+
+instance ToSchema MeetingsJobPayloadTag where
+  schema =
+    enum @Text $
+      element "meetings_cleanup" MeetingsCleanupTag
+
+makePrisms ''MeetingsJobPayload
+
+meetingsJobPayloadObjectSchema :: ObjectSchema SwaggerDoc MeetingsJobPayload
+meetingsJobPayloadObjectSchema = taggedJobPayloadObjectSchema toTag toSchema
+  where
+    toTag :: MeetingsJobPayload -> MeetingsJobPayloadTag
+    toTag =
+      \case
+        MeetingsCleanup {} -> MeetingsCleanupTag
+
+    toSchema :: MeetingsJobPayloadTag -> ObjectSchema SwaggerDoc MeetingsJobPayload
+    toSchema = \case
+      MeetingsCleanupTag -> tag _MeetingsCleanup (field "data" schema)
+
+instance ToSchema MeetingsJobPayload where
+  schema = object meetingsJobPayloadObjectSchema
+
+deriving via (Schema MeetingsJobPayload) instance FromJSON MeetingsJobPayload
+
+deriving via (Schema MeetingsJobPayload) instance ToJSON MeetingsJobPayload
+
+deriving via (Schema MeetingsJobPayload) instance S.ToSchema MeetingsJobPayload
+
+instance Arbitrary MeetingsJobPayload where
+  arbitrary = MeetingsCleanup <$> arbitrary
+
+-- | Payload persisted in the conversations queue. Keep the type tags and
+-- nested data shapes stable when changing job payloads.
+data ConversationsJobPayload
+  = AdminlessSetup AdminlessSetupJob
+  | AdminlessDeletion AdminlessDeletionJob
+  | AdminlessReminder AdminlessReminderJob
+  deriving stock (Eq, Generic, Show)
+
+data ConversationsJobPayloadTag
+  = AdminlessSetupTag
+  | AdminlessReminderTag
+  | AdminlessDeletionTag
+  deriving stock (Eq, Ord, Bounded, Enum, Show, Generic)
+  deriving (Arbitrary) via GenericUniform ConversationsJobPayloadTag
+
+instance ToSchema ConversationsJobPayloadTag where
+  schema =
+    enum @Text $
+      mconcat
+        [ element "adminless_setup" AdminlessSetupTag,
+          element "adminless_deletion" AdminlessDeletionTag,
+          element "adminless_reminder" AdminlessReminderTag
+        ]
+
+makePrisms ''ConversationsJobPayload
+
+conversationsJobPayloadObjectSchema :: ObjectSchema SwaggerDoc ConversationsJobPayload
+conversationsJobPayloadObjectSchema = taggedJobPayloadObjectSchema toTag toSchema
+  where
+    toTag :: ConversationsJobPayload -> ConversationsJobPayloadTag
+    toTag =
+      \case
+        AdminlessSetup {} -> AdminlessSetupTag
+        AdminlessDeletion {} -> AdminlessDeletionTag
+        AdminlessReminder {} -> AdminlessReminderTag
+
+    toSchema :: ConversationsJobPayloadTag -> ObjectSchema SwaggerDoc ConversationsJobPayload
+    toSchema = \case
+      AdminlessSetupTag -> tag _AdminlessSetup (field "data" schema)
+      AdminlessDeletionTag -> tag _AdminlessDeletion (field "data" schema)
+      AdminlessReminderTag -> tag _AdminlessReminder (field "data" schema)
+
+instance ToSchema ConversationsJobPayload where
+  schema = object conversationsJobPayloadObjectSchema
+
+deriving via (Schema ConversationsJobPayload) instance FromJSON ConversationsJobPayload
+
+deriving via (Schema ConversationsJobPayload) instance ToJSON ConversationsJobPayload
+
+deriving via (Schema ConversationsJobPayload) instance S.ToSchema ConversationsJobPayload
+
+instance Arbitrary ConversationsJobPayload where
+  arbitrary = oneof [AdminlessDeletion <$> arbitrary, AdminlessReminder <$> arbitrary]
+
+-- | Registry for the jobs we expose via Arbiter.
+type JobRegistry =
+  '[ Queue MeetingsQueueName MeetingsJobPayload,
+     Queue ConversationsQueueName ConversationsJobPayload
+   ]
diff --git a/libs/wire-api/src/Wire/API/Meeting.hs b/libs/wire-api/src/Wire/API/Meeting.hs
index 44e2c956554..066e8b76563 100644
--- a/libs/wire-api/src/Wire/API/Meeting.hs
+++ b/libs/wire-api/src/Wire/API/Meeting.hs
@@ -15,9 +15,42 @@
 -- You should have received a copy of the GNU Affero General Public License along
 -- with this program. If not, see .
 
-module Wire.API.Meeting where
+module Wire.API.Meeting
+  ( -- * Time zone
+    TimeZone (..),
+    timeZoneTZ,
+    parseTimeZone,
+    renderTimeZone,
+    defaultLegacyTimeZone,
+
+    -- * Meetings (V17 and later)
+    Meeting (..),
+    MeetingWithConversation (..),
+    NewMeeting (..),
+    UpdateMeeting (..),
+
+    -- * Legacy meetings (V15/V16)
+    MeetingV16 (..),
+    MeetingWithConversationV16 (..),
+    NewMeetingV16 (..),
+    UpdateMeetingV16,
+
+    -- * Conversions
+    toLegacy,
+    fromLegacy,
+    toLegacyWithConv,
+    fromLegacyNewMeeting,
+
+    -- * Misc
+    Recurrence (..),
+    Frequency (..),
+    MeetingEmailsInvitation (..),
+  )
+where
 
 import Control.Lens ((?~))
+import Data.Aeson (FromJSON, ToJSON, toJSON)
+import Data.ByteString.Char8 qualified as BS
 import Data.Id (ConvId, MeetingId, UserId)
 import Data.Int qualified as DI
 import Data.Json.Util (utcTimeSchema)
@@ -25,24 +58,58 @@ import Data.OpenApi qualified as S
 import Data.Qualified (Qualified)
 import Data.Range (Range)
 import Data.Schema
+import Data.Text qualified as Text
 import Data.Time.Clock
-import Deriving.Aeson
+import Data.Time.Zones.All (TZLabel (..), fromTZName, toTZName, tzByLabel)
+import Data.Time.Zones.Types (TZ)
 import Imports
+import Test.QuickCheck (elements)
+import Wire.API.Conversation (Conversation, GroupConvType)
 import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..))
 import Wire.API.User.Identity (EmailAddress)
-import Wire.Arbitrary (Arbitrary, GenericUniform (..))
+import Wire.Arbitrary (Arbitrary (..), GenericUniform (..))
+
+-- | An IANA time zone identifier (e.g. @"Europe/Paris"@), backed by the @tz@
+-- package's 'TZLabel'. The loaded 'TZ' is recovered purely via 'timeZoneTZ';
+-- 'TZLabel' itself is what is serialized to JSON and Postgres.
+newtype TimeZone = TimeZone {timeZoneLabel :: TZLabel}
+  deriving stock (Eq, Ord, Show, Generic)
+  deriving newtype (Bounded, Enum)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema TimeZone)
+
+timeZoneTZ :: TimeZone -> TZ
+timeZoneTZ = tzByLabel . timeZoneLabel
+
+parseTimeZone :: Text -> Maybe TimeZone
+parseTimeZone = fmap TimeZone . fromTZName . BS.pack . Text.unpack
+
+renderTimeZone :: TimeZone -> Text
+renderTimeZone = Text.pack . BS.unpack . toTZName . timeZoneLabel
+
+-- | Default for legacy operations (helm @meetings.legacyTimeZone@).
+defaultLegacyTimeZone :: TimeZone
+defaultLegacyTimeZone = TimeZone Europe__Berlin
+
+instance ToSchema TimeZone where
+  schema =
+    renderTimeZone
+      .= parsedText "TimeZone" (maybe (Left "invalid IANA tzid") Right . parseTimeZone)
 
--- | Core Meeting type
+instance Arbitrary TimeZone where
+  arbitrary = TimeZone <$> elements [minBound .. maxBound]
+
+-- | A scheduled meeting (V17 and later). @end_time@ is the source of truth
+-- (there is no @duration@ field); the @tzid@ field carries the IANA time zone.
 data Meeting = Meeting
   { id :: Qualified MeetingId,
     title :: Range 1 256 Text,
     creator :: Qualified UserId,
     startTime :: UTCTime,
     endTime :: UTCTime,
+    tzid :: TimeZone,
     recurrence :: Maybe Recurrence,
     conversationId :: Qualified ConvId,
     invitedEmails :: [EmailAddress],
-    trial :: Bool,
     createdAt :: UTCTime,
     updatedAt :: UTCTime
   }
@@ -50,26 +117,126 @@ data Meeting = Meeting
   deriving (ToJSON, FromJSON, S.ToSchema) via (Schema Meeting)
   deriving (Arbitrary) via (GenericUniform Meeting)
 
+-- | A legacy meeting (V15/V16). Carries @end_time@ (the source of truth) but
+-- has no @tzid@ field; the deprecated @trial@ field is injected (always
+-- @false@) in the 'ToSchema' instance and is never stored.
+data MeetingV16 = MeetingV16
+  { id :: Qualified MeetingId,
+    title :: Range 1 256 Text,
+    creator :: Qualified UserId,
+    startTime :: UTCTime,
+    endTime :: UTCTime,
+    recurrence :: Maybe Recurrence,
+    conversationId :: Qualified ConvId,
+    invitedEmails :: [EmailAddress],
+    createdAt :: UTCTime,
+    updatedAt :: UTCTime
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingV16)
+  deriving (Arbitrary) via (GenericUniform MeetingV16)
+
+-- | V17+ object schema. Carries @end_time@ directly (the source of truth) and
+-- the @tzid@ field.
+meetingObject :: ObjectSchema SwaggerDoc Meeting
+meetingObject =
+  Meeting
+    <$> (.id) .= field "qualified_id" schema
+    <*> (.title) .= field "title" schema
+    <*> (.creator) .= field "qualified_creator" schema
+    <*> (.startTime) .= field "start_time" utcTimeSchema
+    <*> (.endTime) .= field "end_time" utcTimeSchema
+    <*> (.tzid) .= field "tzid" schema
+    <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
+    <*> (.conversationId) .= field "qualified_conversation" schema
+    <*> (.invitedEmails) .= field "invited_emails" (array schema)
+    <*> (.createdAt) .= field "created_at" utcTimeSchema
+    <*> (.updatedAt) .= field "updated_at" utcTimeSchema
+
 instance ToSchema Meeting where
+  schema = objectWithDocModifier (description ?~ "A scheduled meeting") meetingObject
+
+-- | V16 (V15/V16) object schema. Keeps @end_time@ and appends the always-false
+-- @trial@ field (never stored).
+meetingV16Object :: ObjectSchema SwaggerDoc MeetingV16
+meetingV16Object =
+  MeetingV16
+    <$> (.id) .= field "qualified_id" schema
+    <*> (.title) .= field "title" schema
+    <*> (.creator) .= field "qualified_creator" schema
+    <*> (.startTime) .= field "start_time" utcTimeSchema
+    <*> (.endTime) .= field "end_time" utcTimeSchema
+    <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
+    <*> (.conversationId) .= field "qualified_conversation" schema
+    <*> (.invitedEmails) .= field "invited_emails" (array schema)
+    <*> (.createdAt) .= field "created_at" utcTimeSchema
+    <*> (.updatedAt) .= field "updated_at" utcTimeSchema
+    <* ( const ()
+           .= fieldWithDocModifier
+             "trial"
+             (description ?~ "Deprecated. Always false; team meetings are never trial.")
+             (c (False :: Bool))
+       )
+  where
+    -- Constant schema that always encodes @val@ and decodes to @()@, cf. the
+    -- @managed@ field of 'Wire.API.Conversation.ConvTeamInfo'.
+    c :: (ToJSON a) => a -> ValueSchema SwaggerDoc ()
+    c val = mkSchema mempty (const (pure ())) (const (pure (toJSON val)))
+
+instance ToSchema MeetingV16 where
+  schema = objectWithDocModifier (description ?~ "A scheduled meeting") meetingV16Object
+
+-- | A 'Meeting' extended with the full 'Conversation' associated with it, as
+-- returned when creating or updating a meeting. The underlying meeting fields
+-- are flattened into the JSON object (emitted alongside @conversation@).
+data MeetingWithConversation = MeetingWithConversation
+  { meeting :: Meeting,
+    conversation :: Conversation GroupConvType
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingWithConversation)
+  deriving (Arbitrary) via (GenericUniform MeetingWithConversation)
+
+data MeetingWithConversationV16 = MeetingWithConversationV16
+  { meeting :: MeetingV16,
+    conversation :: Conversation GroupConvType
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema MeetingWithConversationV16)
+  deriving (Arbitrary) via (GenericUniform MeetingWithConversationV16)
+
+-- | The V17+ meeting object is flattened into the parent object (its fields
+-- are emitted alongside @conversation@ rather than nested).
+meetingWithConversationObject :: ObjectSchema SwaggerDoc MeetingWithConversation
+meetingWithConversationObject =
+  MeetingWithConversation
+    <$> (.meeting) .= meetingObject
+    <*> (.conversation) .= field "conversation" schema
+
+instance ToSchema MeetingWithConversation where
   schema =
-    objectWithDocModifier (description ?~ "A scheduled meeting") $
-      Meeting
-        <$> (.id) .= field "qualified_id" schema
-        <*> (.title) .= field "title" schema
-        <*> (.creator) .= field "qualified_creator" schema
-        <*> (.startTime) .= field "start_time" utcTimeSchema
-        <*> (.endTime) .= field "end_time" utcTimeSchema
-        <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
-        <*> (.conversationId) .= field "qualified_conversation" schema
-        <*> (.invitedEmails) .= field "invited_emails" (array schema)
-        <*> (.trial) .= field "trial" schema
-        <*> (.createdAt) .= field "created_at" utcTimeSchema
-        <*> (.updatedAt) .= field "updated_at" utcTimeSchema
+    objectWithDocModifier
+      (description ?~ "A scheduled meeting with its associated conversation")
+      meetingWithConversationObject
+
+meetingWithConversationV16Object :: ObjectSchema SwaggerDoc MeetingWithConversationV16
+meetingWithConversationV16Object =
+  MeetingWithConversationV16
+    <$> (.meeting) .= meetingV16Object
+    <*> (.conversation) .= field "conversation" schema
+
+instance ToSchema MeetingWithConversationV16 where
+  schema =
+    objectWithDocModifier
+      (description ?~ "A scheduled meeting with its associated conversation")
+      meetingWithConversationV16Object
 
--- | Request to create a new meeting
+-- | Request to create a new meeting (V17 and later). Carries @end_time@ (the
+-- source of truth) and the @tzid@ field.
 data NewMeeting = NewMeeting
   { startTime :: UTCTime,
     endTime :: UTCTime,
+    tzid :: TimeZone,
     recurrence :: Maybe Recurrence,
     title :: Range 1 256 Text,
     invitedEmails :: [EmailAddress]
@@ -78,6 +245,39 @@ data NewMeeting = NewMeeting
   deriving (ToJSON, FromJSON, S.ToSchema) via (Schema NewMeeting)
   deriving (Arbitrary) via (GenericUniform NewMeeting)
 
+-- | Request to create a new meeting (V15/V16). Carries @end_time@ but no @tzid@.
+data NewMeetingV16 = NewMeetingV16
+  { startTime :: UTCTime,
+    endTime :: UTCTime,
+    recurrence :: Maybe Recurrence,
+    title :: Range 1 256 Text,
+    invitedEmails :: [EmailAddress]
+  }
+  deriving stock (Eq, Show, Generic)
+  deriving (ToJSON, FromJSON, S.ToSchema) via (Schema NewMeetingV16)
+  deriving (Arbitrary) via (GenericUniform NewMeetingV16)
+
+instance ToSchema NewMeeting where
+  schema =
+    objectWithDocModifier (description ?~ "Request to create a new meeting") $
+      NewMeeting
+        <$> (.startTime) .= field "start_time" utcTimeSchema
+        <*> (.endTime) .= field "end_time" utcTimeSchema
+        <*> (.tzid) .= field "tzid" schema
+        <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
+        <*> (.title) .= field "title" schema
+        <*> (.invitedEmails) .= (fromMaybe [] <$> optField "invited_emails" (array schema))
+
+instance ToSchema NewMeetingV16 where
+  schema =
+    objectWithDocModifier (description ?~ "Request to create a new meeting (V16)") $
+      NewMeetingV16
+        <$> (.startTime) .= field "start_time" utcTimeSchema
+        <*> (.endTime) .= field "end_time" utcTimeSchema
+        <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
+        <*> (.title) .= field "title" schema
+        <*> (.invitedEmails) .= (fromMaybe [] <$> optField "invited_emails" (array schema))
+
 data Recurrence = Recurrence
   { -- | The interval between occurrences, e.g., every 2 weeks for Weekly frequency with interval=2
     freq :: Frequency,
@@ -103,28 +303,25 @@ instance ToSchema Frequency where
           element "yearly" Yearly
         ]
 
-instance ToSchema NewMeeting where
-  schema =
-    objectWithDocModifier (description ?~ "Request to create a new meeting") $
-      NewMeeting
-        <$> (.startTime) .= field "start_time" utcTimeSchema
-        <*> (.endTime) .= field "end_time" utcTimeSchema
-        <*> (.recurrence) .= maybe_ (optField "recurrence" schema)
-        <*> (.title) .= field "title" schema
-        <*> (.invitedEmails) .= (fromMaybe [] <$> optField "invited_emails" (array schema))
-
--- | Request to update an existing meeting
+-- | Request to update an existing meeting. @tzid@ is optional: 'Just' sets
+-- the meeting's IANA time zone, while omitting it (as legacy V15\/V16
+-- clients, whose request shape carries no @tzid@, always do) leaves the
+-- stored time zone unchanged; @end_time@ is optional on both eras, so a
+-- single type serves V17 ('UpdateMeeting') and V16 ('UpdateMeetingV16').
 data UpdateMeeting = UpdateMeeting
   { startTime :: Maybe UTCTime,
     endTime :: Maybe UTCTime,
     title :: Maybe (Range 1 256 Text),
     -- | 'Just x' means "set 'recurrence' to 'x', meaning set to a value or unset it"
-    recurrence :: Maybe (Maybe Recurrence)
+    recurrence :: Maybe (Maybe Recurrence),
+    tzid :: Maybe TimeZone
   }
   deriving stock (Eq, Show, Generic)
   deriving (ToJSON, FromJSON, S.ToSchema) via (Schema UpdateMeeting)
   deriving (Arbitrary) via (GenericUniform UpdateMeeting)
 
+type UpdateMeetingV16 = UpdateMeeting
+
 instance ToSchema UpdateMeeting where
   schema =
     objectWithDocModifier (description ?~ "Request to update a meeting") $
@@ -133,6 +330,7 @@ instance ToSchema UpdateMeeting where
         <*> (.endTime) .= maybe_ (optField "end_time" utcTimeSchema)
         <*> (.title) .= maybe_ (optField "title" schema)
         <*> (.recurrence) .= fmap Just (maybe_ (maybe_ (optField' "recurrence" schema)))
+        <*> (.tzid) .= maybe_ (optField "tzid" schema)
 
 instance ToSchema Recurrence where
   schema =
@@ -142,6 +340,61 @@ instance ToSchema Recurrence where
         <*> (.interval) .= (fromMaybe 1 <$> optField "interval" schema)
         <*> (.until) .= maybe_ (optField "until" utcTimeSchema)
 
+-- | Convert a V17 'Meeting' to the legacy 'MeetingV16' shape. Fields are
+-- copied verbatim; @tzid@ is dropped (@end_time@ is preserved, so no duration
+-- needs to be recomputed).
+toLegacy :: Meeting -> MeetingV16
+toLegacy m =
+  MeetingV16
+    { id = m.id,
+      title = m.title,
+      creator = m.creator,
+      startTime = m.startTime,
+      endTime = m.endTime,
+      recurrence = m.recurrence,
+      conversationId = m.conversationId,
+      invitedEmails = m.invitedEmails,
+      createdAt = m.createdAt,
+      updatedAt = m.updatedAt
+    }
+
+-- | Convert a legacy 'MeetingV16' to the V17 'Meeting' shape, injecting the
+-- given 'TimeZone' as @tzid@. All other fields (including @end_time@) are
+-- preserved.
+fromLegacy :: TimeZone -> MeetingV16 -> Meeting
+fromLegacy tz m =
+  Meeting
+    { id = m.id,
+      title = m.title,
+      creator = m.creator,
+      startTime = m.startTime,
+      endTime = m.endTime,
+      tzid = tz,
+      recurrence = m.recurrence,
+      conversationId = m.conversationId,
+      invitedEmails = m.invitedEmails,
+      createdAt = m.createdAt,
+      updatedAt = m.updatedAt
+    }
+
+-- | 'toLegacy' lifted over 'MeetingWithConversation'.
+toLegacyWithConv :: MeetingWithConversation -> MeetingWithConversationV16
+toLegacyWithConv mwc =
+  MeetingWithConversationV16 {meeting = toLegacy mwc.meeting, conversation = mwc.conversation}
+
+-- | Convert a V16 'NewMeetingV16' to the V17 'NewMeeting', injecting the given
+-- 'TimeZone' as @tzid@. @end_time@ is preserved (it is the source of truth).
+fromLegacyNewMeeting :: TimeZone -> NewMeetingV16 -> NewMeeting
+fromLegacyNewMeeting tz nm =
+  NewMeeting
+    { startTime = nm.startTime,
+      endTime = nm.endTime,
+      tzid = tz,
+      recurrence = nm.recurrence,
+      title = nm.title,
+      invitedEmails = nm.invitedEmails
+    }
+
 -- | Request to add/remove invited email
 newtype MeetingEmailsInvitation = MeetingEmailsInvitation
   { emails :: [EmailAddress]
diff --git a/libs/wire-api/src/Wire/API/OAuth.hs b/libs/wire-api/src/Wire/API/OAuth.hs
index a3ff2db1537..9392b7f909e 100644
--- a/libs/wire-api/src/Wire/API/OAuth.hs
+++ b/libs/wire-api/src/Wire/API/OAuth.hs
@@ -29,6 +29,7 @@ import Data.ByteString.Lazy (fromStrict, toStrict)
 import Data.HashMap.Strict qualified as HM
 import Data.Id as Id
 import Data.Json.Util
+import Data.Map qualified as Map
 import Data.OpenApi (ToParamSchema (..))
 import Data.OpenApi qualified as S
 import Data.Range
@@ -199,7 +200,10 @@ data OAuthScope
   | ReadSelf
   | WriteConversations
   | WriteConversationsCode
-  deriving (Eq, Show, Generic, Ord)
+  | WriteConversationsName
+  | WriteMeetings
+  | AdminMeetings
+  deriving (Eq, Show, Generic, Ord, Bounded, Enum)
   deriving (Arbitrary) via (GenericUniform OAuthScope)
 
 class IsOAuthScope scope where
@@ -217,22 +221,32 @@ instance IsOAuthScope 'ReadSelf where
 instance IsOAuthScope 'ReadFeatureConfigs where
   toOAuthScope = ReadFeatureConfigs
 
+instance IsOAuthScope 'WriteConversationsName where
+  toOAuthScope = WriteConversationsName
+
+instance IsOAuthScope 'WriteMeetings where
+  toOAuthScope = WriteMeetings
+
+instance IsOAuthScope 'AdminMeetings where
+  toOAuthScope = AdminMeetings
+
 instance ToByteString OAuthScope where
   builder = \case
     WriteConversations -> "write:conversations"
     WriteConversationsCode -> "write:conversations_code"
+    WriteConversationsName -> "write:conversations_name"
+    WriteMeetings -> "write:meetings"
+    AdminMeetings -> "admin:meetings"
     ReadSelf -> "read:self"
     ReadFeatureConfigs -> "read:feature_configs"
 
 instance FromByteString OAuthScope where
   parser = do
-    s <- parser
-    case T.toLower s of
-      "write:conversations" -> pure WriteConversations
-      "write:conversations_code" -> pure WriteConversationsCode
-      "read:self" -> pure ReadSelf
-      "read:feature_configs" -> pure ReadFeatureConfigs
-      _ -> fail "invalid scope"
+    s <- (toByteString' . T.toLower) <$> parser
+    let table = Map.fromList [(toByteString' c, c) | c <- [(minBound :: OAuthScope) ..]]
+    case Map.lookup s table of
+      Just c -> pure c
+      Nothing -> fail $ "invalid scope: " <> show s
 
 newtype OAuthScopes = OAuthScopes {unOAuthScopes :: Set OAuthScope}
   deriving (Eq, Show, Generic, Monoid, Semigroup, Arbitrary)
diff --git a/libs/wire-api/src/Wire/API/Routes/Features.hs b/libs/wire-api/src/Wire/API/Routes/Features.hs
index 5759e37659e..fc8d7eb43b3 100644
--- a/libs/wire-api/src/Wire/API/Routes/Features.hs
+++ b/libs/wire-api/src/Wire/API/Routes/Features.hs
@@ -19,6 +19,7 @@ module Wire.API.Routes.Features where
 
 import Wire.API.Conversation.Role
 import Wire.API.Error.Galley
+import Wire.API.Routes.Version (Version (V18))
 import Wire.API.Team.Feature
 
 type family FeatureErrors cfg where
@@ -39,3 +40,8 @@ type family FeatureAPIDesc cfg where
   FeatureAPIDesc RequireExternalEmailVerificationConfig =
     "

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

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

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

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

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

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

" + VersionedFeatureAPIDesc _ cfg = FeatureAPIDesc cfg diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index 4a0e907c808..7ef9eead11a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -535,6 +535,19 @@ type AccountAPI = :> ReqBody '[Servant.JSON] ManagedByUpdate :> Put '[Servant.JSON] NoContent ) + :<|> Named + "iDeletePendingEmailUpdate" + ( Summary + "Invalidate a pending email-address update for a user. \ + \Used by spar when a user is put under SCIM control, so that team \ + \settings no longer offers a 'resend verification' action that cannot \ + \succeed, and stale activation links cannot change a SCIM-managed \ + \user's email." + :> "users" + :> Capture "uid" UserId + :> "pending-email-update" + :> Delete '[Servant.JSON] NoContent + ) :<|> Named "iPutRichInfo" ( "users" @@ -566,13 +579,6 @@ type AccountAPI = :> "rich-info" :> Get '[Servant.JSON] RichInfo ) - :<|> Named - "iGetRichInfoMulti" - ( "users" - :> "rich-info" - :> QueryParam' '[Optional, Strict] "ids" (CommaSeparatedList UserId) - :> Get '[Servant.JSON] GetRichInfoMultiResponse - ) :<|> Named "iHeadHandle" ( CanThrow 'InvalidHandle diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs index 6216cea445a..669b0f1fe7a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs @@ -14,6 +14,24 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -Wno-deprecations #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . module Wire.API.Routes.Internal.Galley where @@ -98,8 +116,8 @@ type IFeatureAPI = :<|> IFeatureStatusLockStatusPut SimplifiedUserConnectionRequestQRCodeConfig :<|> IFeatureStatusLockStatusPut StealthUsersConfig :<|> IFeatureStatusLockStatusPut MeetingsConfig - :<|> IFeatureStatusLockStatusPut MeetingsPremiumConfig - :<|> IFeatureStatusLockStatusPut BackgroundEffectsConfig + :<|> Until 'V17 ::> IFeatureStatusLockStatusPut MeetingsPremiumConfig + :<|> Until 'V17 ::> IFeatureStatusLockStatusPut BackgroundEffectsConfig -- all feature configs :<|> Named "feature-configs-internal" diff --git a/libs/wire-api/src/Wire/API/Routes/Public.hs b/libs/wire-api/src/Wire/API/Routes/Public.hs index e651202e9b6..cacd78420dd 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public.hs @@ -31,6 +31,7 @@ module Wire.API.Routes.Public ZProvider, ZAccess, DescriptionOAuthScope, + renderOAuthScope, ZHostOpt, ZHostValue, ZAuthServant, @@ -227,7 +228,7 @@ instance (HasLink api) => HasLink (ZHostOpt :> api) where type MkLink (ZHostOpt :> api) a = MkLink api a toLink toA _ = toLink toA (Proxy :: Proxy api) -type ZHostValue = Text -- FUTUREWORK: use Data.Domain.Domain here instead of Text? +type ZHostValue = Domain type ZOptHostHeader = Header' '[Servant.Optional, Strict] "Z-Host" ZHostValue @@ -281,7 +282,7 @@ instance ) => HasServer (ZHostOpt :> api) ctx where - type ServerT (ZHostOpt :> api) m = Maybe Text -> ServerT api m + type ServerT (ZHostOpt :> api) m = Maybe ZHostValue -> ServerT api m route :: Proxy (ZHostOpt :> api) -> Context ctx -> @@ -352,17 +353,19 @@ instance addScopeDescription :: forall scope. (OAuth.IsOAuthScope scope) => OpenApi -> OpenApi addScopeDescription = - allOperations - . description - %~ Just - . ( <> - "\nOAuth scope: `" - <> ( decodeUtf8With lenientDecode . toStrict . toByteString $ - OAuth.toOAuthScope @scope - ) - <> "`" - ) - . fold + allOperations . description %~ Just . (<> renderOAuthScope (OAuth.toOAuthScope @scope)) . fold + +-- | The snippet 'DescriptionOAuthScope' appends to an operation description. +-- +-- This is enforced in @charts/nginz/values.yaml@ (search for +-- @oauth_scope@). Mismatches are caught in +-- @Test.Wire.API.Routes.OAuthScopes@. (This function is exported for +-- that test only.) +renderOAuthScope :: OAuth.OAuthScope -> Text +renderOAuthScope scope = + "\nOAuth scope: `" + <> (decodeUtf8With lenientDecode . toStrict . toByteString $ scope) + <> "`" instance (HasServer api ctx) => HasServer (DescriptionOAuthScope scope :> api) ctx where type ServerT (DescriptionOAuthScope scope :> api) m = ServerT api m diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index 16b4bc0e09b..fd3c3901731 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -70,7 +70,6 @@ import Wire.API.Routes.QualifiedCapture import Wire.API.Routes.Version import Wire.API.Routes.Versioned import Wire.API.SystemSettings -import Wire.API.Team.Collaborator import Wire.API.Team.Invitation import Wire.API.Team.Size import Wire.API.User hiding (NoIdentity) @@ -238,6 +237,7 @@ type UserAPI = :> ZUser :> From 'V4 :> "list-users" + :> QueryParam' [Optional, Strict, Description "Include whether each local user can currently be contacted"] "include-contact-status" Bool :> ReqBody '[JSON] ListUsersQuery :> Post '[JSON] ListUsersById ) @@ -669,7 +669,7 @@ type AccountAPI = Named "register@v16" ( Summary "Register a new user." - :> Until 'V17 + :> Until 'V18 :> Description "If the environment where the registration takes \ \place is private and a registered email address \ @@ -682,7 +682,7 @@ type AccountAPI = :<|> Named "register" ( Summary "Register a new user." - :> From 'V17 + :> From 'V18 :> Description "If the environment where the registration takes \ \place is private and a registered email address \ @@ -2097,27 +2097,6 @@ type TeamsAPI = :> ReqBody '[JSON] AcceptTeamInvitation :> MultiVerb 'POST '[JSON] '[RespondEmpty 200 "Team invitation accepted."] () ) - :<|> Named - "add-team-collaborator" - ( Summary "Add a collaborator to the team." - :> From 'V10 - :> ZLocalUser - :> "teams" - :> Capture "tid" TeamId - :> "collaborators" - :> ReqBody '[JSON] NewTeamCollaborator - :> MultiVerb1 'POST '[JSON] (RespondEmpty 200 "") - ) - :<|> Named - "get-team-collaborators" - ( Summary "Get all collaborators of the team." - :> From 'V10 - :> ZLocalUser - :> "teams" - :> Capture "tid" TeamId - :> "collaborators" - :> MultiVerb1 'GET '[JSON] (Respond 200 "Return collaborators" [TeamCollaborator]) - ) type SystemSettingsAPI = Named diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs index 9117d6c58c6..68bb60a9d3f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs @@ -534,6 +534,7 @@ type ConversationAPI = :<|> Named "create-group-conversation" ( Summary "Create a new conversation" + :> DescriptionOAuthScope 'WriteConversations :> From 'V16 :> CanThrow 'ConvAccessDenied :> CanThrow 'MLSNonEmptyMemberList @@ -955,7 +956,7 @@ type ConversationAPI = -- - MemberJoin event for added members -- - MemberLeave event for removed members :<|> Named - "replace-members-in-conversation" + "replace-members-in-conversation@v16" ( Summary "Replace the members of a conversation." :> Description "This will add any members not already in the conversation, \ @@ -964,9 +965,42 @@ type ConversationAPI = \The roles of already existing members will not be changed \ \even if these members are included in the request body and their role differs from the role provided in this request." :> From 'V13 + :> Until 'V18 + :> CanThrow ('ActionDenied 'AddConversationMember) + :> CanThrow ('ActionDenied 'RemoveConversationMember) + :> CanThrow ('ActionDenied 'LeaveConversation) + :> CanThrow 'ConvNotFound + :> CanThrow 'InvalidOperation + :> CanThrow 'TooManyMembers + :> CanThrow 'ConvAccessDenied + :> CanThrow 'NotATeamMember + :> CanThrow 'NotConnected + :> CanThrow 'MissingLegalholdConsent + :> CanThrow 'GroupIdVersionNotSupported + :> CanThrow NonFederatingBackends + :> CanThrow UnreachableBackends + :> ZLocalUser + :> ZConn + :> "conversations" + :> QualifiedCapture "cnv" ConvId + :> "members" + :> ReqBody '[Servant.JSON] InviteQualified + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "Conversation members replaced") + ) + :<|> Named + "replace-members-in-conversation" + ( Summary "Replace the members of a conversation." + :> Description + "This will add any members not already in the conversation, \ + \and remove any members not in the provided list except users that are associated via a user group. \ + \The given role in the request body will be applied to all added members. \ + \The roles of already existing members will not be changed \ + \even if these members are included in the request body and their role differs from the role provided in this request." + :> From 'V18 :> CanThrow ('ActionDenied 'AddConversationMember) :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow ('ActionDenied 'LeaveConversation) + :> CanThrow AdminlessConversation :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation :> CanThrow 'TooManyMembers @@ -1117,6 +1151,7 @@ type ConversationAPI = :<|> Named "get-code" ( Summary "Get existing conversation code" + :> DescriptionOAuthScope 'WriteConversationsCode :> CanThrow 'CodeNotFound :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvNotFound @@ -1305,6 +1340,7 @@ type ConversationAPI = :<|> Named "update-conversation-name" ( Summary "Update conversation name" + :> DescriptionOAuthScope 'WriteConversationsName :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index 0fac708542c..923ecc7d4a5 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -14,6 +14,24 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -Wno-deprecations #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . module Wire.API.Routes.Public.Galley.Feature where @@ -21,6 +39,7 @@ import Data.Id import GHC.TypeLits import Servant import Servant.OpenApi.Internal.Orphans () +import Wire.API.Deprecated import Wire.API.Error import Wire.API.Error.Galley import Wire.API.OAuth @@ -68,8 +87,8 @@ type FeatureAPI = :<|> FeatureAPIGet DomainRegistrationConfig :<|> FeatureAPIGetPut ChannelsConfig :<|> FeatureAPIGet PreventAdminlessGroupsConfig - :<|> Until 'V17 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v16" V16 PreventAdminlessGroupsConfig - :<|> From 'V17 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v17" V17 PreventAdminlessGroupsConfig + :<|> Until 'V18 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v16" V16 PreventAdminlessGroupsConfig + :<|> From 'V18 ::> VersionedFeatureAPIPut "put-PreventAdminlessGroupsConfig@v18" V18 PreventAdminlessGroupsConfig :<|> FeatureAPIGet CellsConfig :<|> Until 'V14 ::> VersionedFeatureAPIPut "put-CellsConfig@v13" V13 CellsConfig :<|> From 'V14 ::> FeatureAPIPut CellsConfig @@ -82,13 +101,14 @@ type FeatureAPI = :<|> FeatureAPIGet StealthUsersConfig :<|> FeatureAPIGet CellsInternalConfig :<|> FeatureAPIGetPut MeetingsConfig - :<|> FeatureAPIGetPut MeetingsPremiumConfig - :<|> FeatureAPIGetPut BackgroundEffectsConfig + :<|> Deprecated ::> Until 'V17 ::> FeatureAPIGet MeetingsPremiumConfig + :<|> Deprecated ::> Until 'V17 ::> FeatureAPIPut MeetingsPremiumConfig + :<|> Deprecated ::> Until 'V17 ::> FeatureAPIGetPut BackgroundEffectsConfig type VersionedFeatureAPIPut named reqBodyVersion cfg = Named named - ( Description (FeatureAPIDesc cfg) + ( Description (VersionedFeatureAPIDesc reqBodyVersion cfg) :> ZUser :> Summary (AppendSymbol "Put config for " (FeatureSymbol cfg)) :> CanThrow OperationDenied diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs index 418b6c803ab..db65df71470 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Meetings.hs @@ -23,6 +23,7 @@ import Servant import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Meeting +import Wire.API.OAuth import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -30,37 +31,77 @@ import Wire.API.Routes.Version type MeetingsAPI = Named - "create-meeting" + "create-meeting@v15" ( Summary "Create a new meeting" :> From 'V15 + :> Until 'V17 :> ZLocalUser + :> ZConn :> "meetings" - :> ReqBody '[JSON] NewMeeting - :> CanThrow 'InvalidOperation + :> ReqBody '[JSON] NewMeetingV16 + :> CanThrow MeetingError :> CanThrow UnreachableBackends :> MultiVerb 'POST '[JSON] - '[Respond 201 "Meeting created" Meeting] - Meeting + '[Respond 201 "Meeting created" MeetingWithConversationV16] + MeetingWithConversationV16 ) :<|> Named - "update-meeting" + "create-meeting" + ( Summary "Create a new meeting" + :> DescriptionOAuthScope 'WriteMeetings + :> From 'V17 + :> ZLocalUser + :> ZConn + :> "meetings" + :> ReqBody '[JSON] NewMeeting + :> CanThrow MeetingError + :> CanThrow UnreachableBackends + :> MultiVerb + 'POST + '[JSON] + '[Respond 201 "Meeting created" MeetingWithConversation] + MeetingWithConversation + ) + :<|> Named + "update-meeting@v15" ( Summary "Update an existing meeting" :> From 'V15 + :> Until 'V17 + :> ZLocalUser + :> ZConn + :> "meetings" + :> Capture "domain" Domain + :> Capture "id" MeetingId + :> CanThrow 'MeetingNotFound + :> CanThrow 'AccessDenied + :> CanThrow MeetingError + :> ReqBody '[JSON] UpdateMeetingV16 + :> MultiVerb + 'PUT + '[JSON] + '[Respond 200 "Meeting updated" MeetingWithConversationV16] + MeetingWithConversationV16 + ) + :<|> Named + "update-meeting" + ( Summary "Update an existing meeting" + :> From 'V17 :> ZLocalUser + :> ZConn :> "meetings" :> Capture "domain" Domain :> Capture "id" MeetingId :> CanThrow 'MeetingNotFound :> CanThrow 'AccessDenied - :> CanThrow 'InvalidOperation + :> CanThrow MeetingError :> ReqBody '[JSON] UpdateMeeting :> MultiVerb 'PUT '[JSON] - '[Respond 200 "Meeting updated" Meeting] - Meeting + '[Respond 200 "Meeting updated" MeetingWithConversation] + MeetingWithConversation ) :<|> Named "delete-meeting" @@ -73,6 +114,7 @@ type MeetingsAPI = :> Capture "id" MeetingId :> CanThrow 'MeetingNotFound :> CanThrow 'AccessDenied + :> CanThrow MeetingError :> MultiVerb 'DELETE '[JSON] @@ -80,9 +122,24 @@ type MeetingsAPI = () ) :<|> Named - "get-meeting" + "get-meeting@v15" ( Summary "Get a single meeting by ID" :> From 'V15 + :> Until 'V17 + :> ZLocalUser + :> "meetings" + :> Capture "domain" Domain + :> Capture "id" MeetingId + :> CanThrow 'MeetingNotFound + :> MultiVerb1 + 'GET + '[JSON] + (Respond 200 "A single meeting by ID" MeetingV16) + ) + :<|> Named + "get-meeting" + ( Summary "Get a single meeting by ID" + :> From 'V17 :> ZLocalUser :> "meetings" :> Capture "domain" Domain @@ -91,9 +148,22 @@ type MeetingsAPI = :> Get '[JSON] Meeting ) :<|> Named - "list-meetings" + "list-meetings@v16" ( Summary "List all meetings for the authenticated user" :> From 'V16 + :> Until 'V17 + :> ZLocalUser + :> "meetings" + :> "list" + :> MultiVerb1 + 'GET + '[JSON] + (Respond 200 "List of meetings for the authenticated user" [MeetingV16]) + ) + :<|> Named + "list-meetings" + ( Summary "List all meetings for the authenticated user" + :> From 'V17 :> ZLocalUser :> "meetings" :> "list" @@ -110,6 +180,7 @@ type MeetingsAPI = :> "invitations" :> CanThrow 'MeetingNotFound :> CanThrow 'AccessDenied + :> CanThrow MeetingError :> ReqBody '[JSON] MeetingEmailsInvitation :> MultiVerb 'POST @@ -129,6 +200,7 @@ type MeetingsAPI = :> "delete" :> CanThrow 'MeetingNotFound :> CanThrow 'AccessDenied + :> CanThrow MeetingError :> ReqBody '[JSON] MeetingEmailsInvitation :> MultiVerb 'POST @@ -147,6 +219,7 @@ type MeetingsAPI = :> "invitations" :> CanThrow 'MeetingNotFound :> CanThrow 'AccessDenied + :> CanThrow MeetingError :> ReqBody '[JSON] MeetingEmailsInvitation :> MultiVerb 'PUT diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs index e28ac848bbc..33044bfcc0a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs @@ -209,6 +209,27 @@ type TeamMemberAPI = "CSV of team members" CSV ) + :<|> Named + "add-team-collaborator" + ( Summary "Add a collaborator to the team." + :> From 'V10 + :> ZLocalUser + :> "teams" + :> Capture "tid" TeamId + :> "collaborators" + :> ReqBody '[JSON] NewTeamCollaborator + :> MultiVerb1 'POST '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "get-team-collaborators" + ( Summary "Get all collaborators of the team." + :> From 'V10 + :> ZLocalUser + :> "teams" + :> Capture "tid" TeamId + :> "collaborators" + :> MultiVerb1 'GET '[JSON] (Respond 200 "Return collaborators" [TeamCollaborator]) + ) :<|> Named "update-team-collaborator" ( Summary "Update a collaborator permissions from the team." diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs new file mode 100644 index 00000000000..63791ced758 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs @@ -0,0 +1,75 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | The swagger docs for the public API of the development version. +-- +-- Older versions are frozen and served from the pregenerated JSON files in +-- @services/brig/docs/@; this is the only version that is still assembled from +-- the routing tables. It lives here rather than in brig so that tests in this +-- package can get at it -- see @Test.Wire.API.Routes.OAuthScopes@. +module Wire.API.Routes.Public.Swagger + ( devVersion, + devVersionSwagger, + ) +where + +import Control.Lens ((.~)) +import Data.OpenApi qualified as S +import Imports +import Servant.API (toUrlPiece) +import Wire.API.Routes.API (serviceSwagger) +import Wire.API.Routes.Public.Brig (BrigAPITag) +import Wire.API.Routes.Public.Brig.OAuth (OAuthAPITag) +import Wire.API.Routes.Public.Cannon (CannonAPITag) +import Wire.API.Routes.Public.Cargohold (CargoholdAPITag) +import Wire.API.Routes.Public.Galley (GalleyAPITag) +import Wire.API.Routes.Public.Gundeck (GundeckAPITag) +import Wire.API.Routes.Public.Proxy (ProxyAPITag) +import Wire.API.Routes.Public.Spar (SparAPITag) +import Wire.API.Routes.Version +import Wire.API.SwaggerHelper (cleanupSwagger) + +-- | The version 'devVersionSwagger' describes. Must stay in sync with the type +-- level @\'V18@ below; there is no way to tie the two together, since the +-- 'S.OpenApi' has to be assembled at a statically known version. +devVersion :: Version +devVersion = + if maxBound == V18 + then maxBound + else + -- if you get this error, you also need to update the version literals below. + error "libs/wire-api/src/Wire/API/Routes/Public/Swagger.hs#devVersion: please update to latest api version!" + +-- | Note that brig additionally sets @info.description@ from +-- @services/brig/docs/swagger.md@, which cannot move here: it is embedded +-- relative to the brig package. 'cleanupSwagger' does not touch +-- @info.description@, so setting it afterwards is equivalent. +devVersionSwagger :: S.OpenApi +devVersionSwagger = + ( serviceSwagger @VersionAPITag @'V18 + <> serviceSwagger @BrigAPITag @'V18 + <> serviceSwagger @GalleyAPITag @'V18 + <> serviceSwagger @SparAPITag @'V18 + <> serviceSwagger @CargoholdAPITag @'V18 + <> serviceSwagger @CannonAPITag @'V18 + <> serviceSwagger @GundeckAPITag @'V18 + <> serviceSwagger @ProxyAPITag @'V18 + <> serviceSwagger @OAuthAPITag @'V18 + ) + & S.info . S.title .~ "Wire-Server API" + & S.servers .~ [S.Server ("/" <> toUrlPiece devVersion) Nothing mempty] + & cleanupSwagger diff --git a/libs/wire-api/src/Wire/API/Routes/Version.hs b/libs/wire-api/src/Wire/API/Routes/Version.hs index 330e6f00bb3..119c62b3dfa 100644 --- a/libs/wire-api/src/Wire/API/Routes/Version.hs +++ b/libs/wire-api/src/Wire/API/Routes/Version.hs @@ -103,7 +103,7 @@ import Wire.Arbitrary (Arbitrary, GenericUniform (GenericUniform)) -- and 'developmentVersions' stay in sync; everything else here should keep working without -- change. See also documentation in the *docs* directory. -- https://docs.wire.com/developer/developer/api-versioning.html#version-bump-checklist -data Version = V0 | V1 | V2 | V3 | V4 | V5 | V6 | V7 | V8 | V9 | V10 | V11 | V12 | V13 | V14 | V15 | V16 | V17 +data Version = V0 | V1 | V2 | V3 | V4 | V5 | V6 | V7 | V8 | V9 | V10 | V11 | V12 | V13 | V14 | V15 | V16 | V17 | V18 deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) deriving (FromJSON, ToJSON) via (Schema Version) deriving (Arbitrary) via (GenericUniform Version) @@ -142,6 +142,8 @@ instance RenderableSymbol V16 where renderSymbol = "V16" instance RenderableSymbol V17 where renderSymbol = "V17" +instance RenderableSymbol V18 where renderSymbol = "V18" + -- | Manual enumeration of version integrals (the `` in the constructor `V`). -- -- This is not the same as 'fromEnum': we will remove unsupported versions in the future, @@ -167,6 +169,7 @@ versionInt V14 = 14 versionInt V15 = 15 versionInt V16 = 16 versionInt V17 = 17 +versionInt V18 = 18 supportedVersions :: [Version] supportedVersions = [minBound .. maxBound] @@ -291,7 +294,8 @@ isDevelopmentVersion V13 = False isDevelopmentVersion V14 = False isDevelopmentVersion V15 = False isDevelopmentVersion V16 = False -isDevelopmentVersion V17 = True +isDevelopmentVersion V17 = False +isDevelopmentVersion V18 = True developmentVersions :: [Version] developmentVersions = filter isDevelopmentVersion supportedVersions diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index 7aa8043313e..28563253014 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -4,6 +4,7 @@ {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE StrictData #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} -- This file is part of the Wire Server implementation. -- @@ -1435,13 +1436,13 @@ instance ToSchema (Versioned V16 PreventAdminlessGroupsConfig) where instance ToObjectSchema (Versioned V16 PreventAdminlessGroupsConfig) where objectSchema = field "config" schema -instance ToSchema (Versioned V17 PreventAdminlessGroupsConfig) where +instance ToSchema (Versioned V18 PreventAdminlessGroupsConfig) where schema = object $ Versioned <$> unVersioned .= durationPreventAdminlessGroupsConfigObjectSchema -instance ToObjectSchema (Versioned V17 PreventAdminlessGroupsConfig) where +instance ToObjectSchema (Versioned V18 PreventAdminlessGroupsConfig) where objectSchema = field "config" schema oldPreventAdminlessGroupsConfigObjectSchema :: ObjectSchema SwaggerDoc PreventAdminlessGroupsConfig @@ -1624,7 +1625,16 @@ instance IsFeatureConfig MlsE2EIdConfig where data MlsMigrationConfigB t f = MlsMigrationConfig { startTime :: Wear t f (Maybe UTCTime), - finaliseRegardlessAfter :: Wear t f (Maybe UTCTime) + finaliseRegardlessAfter :: Wear t f (Maybe UTCTime), + -- | Allow users to manually trigger migrations from Proteus to MLS for + -- group conversations. + -- + -- There is no logic behind this flag in the backend. It is solely meant for + -- clients to decide if they should show a button (to facilitate MLS + -- migration for a group conversation) or not. + -- + -- The default is `False`. + allowManualMigration :: Wear t f Bool } deriving (BareB, Generic) @@ -1647,24 +1657,27 @@ deriving via (BarbieFeature MlsMigrationConfigB) instance (ToSchema MlsMigration deriving via (RenderableTypeName MlsMigrationConfig) instance (RenderableSymbol MlsMigrationConfig) instance Default MlsMigrationConfig where - def = MlsMigrationConfig Nothing Nothing + def = MlsMigrationConfig Nothing Nothing False instance Arbitrary MlsMigrationConfig where arbitrary = do startTime <- fmap fromUTCTimeMillis <$> arbitrary finaliseRegardlessAfter <- fmap fromUTCTimeMillis <$> arbitrary + allowManualMigration <- arbitrary pure MlsMigrationConfig { startTime = startTime, - finaliseRegardlessAfter = finaliseRegardlessAfter + finaliseRegardlessAfter = finaliseRegardlessAfter, + allowManualMigration = allowManualMigration } -instance (Typeable f, NestedMaybe f) => ToSchema (MlsMigrationConfigB Covered f) where +instance (Typeable f, NestedMaybe f, OptWithDefault f) => ToSchema (MlsMigrationConfigB Covered f) where schema = object $ MlsMigrationConfig <$> startTime .= nestedMaybeField "startTime" (unnamed utcTimeSchema) <*> finaliseRegardlessAfter .= nestedMaybeField "finaliseRegardlessAfter" (unnamed utcTimeSchema) + <*> allowManualMigration .= fromOpt (optField "allowManualMigration" (schema @Bool)) instance Default (LockableFeature MlsMigrationConfig) where def = defLockedFeature @@ -2399,8 +2412,12 @@ instance ToObjectSchema MeetingsConfig where -------------------------------------------------------------------------------- -- MeetingPremium Feature -- --- Indicates whether a team has premium meetings features. When enabled, meetings --- created by team members are not marked as trial. When disabled, meetings are trial. +-- /Deprecated (WPB-26771)./ This feature flag no longer affects meeting +-- behaviour: team meetings are always non-trial. It is kept solely for API +-- compatibility (the public\/internal endpoints still exist) and defaults to +-- /enabled and locked/. Scheduled for removal in a future release. + +{-# DEPRECATED MeetingsPremiumConfig "Deprecated (WPB-26771): no longer affects meeting trial status; kept for API compatibility." #-} data MeetingsPremiumConfig = MeetingsPremiumConfig deriving (Eq, Show, Generic, GSOP.Generic) @@ -2412,7 +2429,7 @@ instance ToSchema MeetingsPremiumConfig where schema = object objectSchema instance Default (LockableFeature MeetingsPremiumConfig) where - def = defLockedFeature + def = defLockedFeature {status = FeatureStatusEnabled} instance IsFeatureConfig MeetingsPremiumConfig where type FeatureSymbol MeetingsPremiumConfig = "meetingsPremium" @@ -2423,9 +2440,12 @@ instance ToObjectSchema MeetingsPremiumConfig where -------------------------------------------------------------------------------- -- BackgroundEffects Feature --- --- Controls whether background effects are available in meetings. +{-# DEPRECATED BackgroundEffectsConfig "Deprecated (WPB-27912): no longer affects meeting behaviour; kept for API compatibility." #-} + +-- | /Deprecated (WPB-27912)./ This feature flag no longer affects meeting +-- behaviour and is kept solely for API compatibility. It defaults to +-- /enabled and locked/. Scheduled for removal in a future release. data BackgroundEffectsConfig = BackgroundEffectsConfig deriving (Eq, Show, Generic, GSOP.Generic) deriving (Arbitrary) via (GenericUniform BackgroundEffectsConfig) @@ -2436,7 +2456,7 @@ instance ToSchema BackgroundEffectsConfig where schema = object objectSchema instance Default (LockableFeature BackgroundEffectsConfig) where - def = defLockedFeature + def = defLockedFeature {status = FeatureStatusEnabled} instance IsFeatureConfig BackgroundEffectsConfig where type FeatureSymbol BackgroundEffectsConfig = "backgroundEffects" diff --git a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs index 07e9ea6def4..ed7e457a9a7 100644 --- a/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs +++ b/libs/wire-api/src/Wire/API/Team/FeatureFlags.hs @@ -1,6 +1,7 @@ {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StrictData #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} -- This file is part of the Wire Server implementation. -- @@ -165,7 +166,11 @@ instance Default (FeatureDefaults SearchVisibilityAvailableConfig) where def = FeatureTeamSearchVisibilityAvailableByDefault instance ParseFeatureDefaults (FeatureDefaults SearchVisibilityAvailableConfig) where - parseFeatureDefaults obj = obj .: "teamSearchVisibility" + parseFeatureDefaults obj = do + -- Accept both the current feature key and the legacy team-scoped key. + mCurrent <- obj .:? "searchVisibility" + mLegacy <- obj .:? "teamSearchVisibility" + pure $ fromMaybe def (mCurrent <|> mLegacy) instance FromJSON (FeatureDefaults SearchVisibilityAvailableConfig) where parseJSON (String "enabled-by-default") = pure FeatureTeamSearchVisibilityAvailableByDefault diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 6e7bd0ba119..161040a456e 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -31,6 +31,8 @@ module Wire.API.User UserSet (..), -- Profiles UserProfile (..), + ContactStatus (..), + ContactStatusState (..), SelfProfile (..), -- User (should not be here) User (..), @@ -545,7 +547,8 @@ data UserProfile = UserProfile profileSupportedProtocols :: Set BaseProtocolTag, profileType :: UserType, profileApp :: Maybe AppInfo, - profileSearchable :: Bool + profileSearchable :: Bool, + profileContactStatus :: Maybe ContactStatus } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UserProfile) @@ -589,6 +592,30 @@ userProfileObjectSchema = <*> profileType .= fmap (fromMaybe UserTypeRegular) (optField "type" schema) <*> profileApp .= maybe_ (optField "app" schema) <*> profileSearchable .= fmap (fromMaybe True) (optField "searchable" schema) + <*> profileContactStatus .= maybe_ (optField "contact_status" schema) + +data ContactStatusState + = Contactable + | NonContactable + deriving stock (Eq, Ord, Show, Generic) + deriving (Arbitrary) via (GenericUniform ContactStatusState) + deriving (FromJSON, ToJSON, S.ToSchema) via (Schema ContactStatusState) + +instance ToSchema ContactStatusState where + schema = + enum @Text $ + element "contactable" Contactable + <> element "non-contactable" NonContactable + +data ContactStatus = ContactStatus + { contactStatusState :: ContactStatusState + } + deriving stock (Eq, Ord, Show, Generic) + deriving (Arbitrary) via (GenericUniform ContactStatus) + deriving (FromJSON, ToJSON, S.ToSchema) via (Schema ContactStatus) + +instance ToSchema ContactStatus where + schema = object $ ContactStatus <$> contactStatusState .= field "state" schema -------------------------------------------------------------------------------- -- SelfProfile @@ -777,7 +804,8 @@ mkUserProfileWithEmail memail u mba legalHoldStatus = profileSupportedProtocols = userSupportedProtocols u, profileType = u.userType, profileApp = mba, - profileSearchable = userSearchable u + profileSearchable = userSearchable u, + profileContactStatus = Nothing } mkUserProfile :: EmailVisibilityConfigWithViewer -> User -> Maybe AppInfo -> UserLegalHoldStatus -> UserProfile diff --git a/libs/wire-api/src/Wire/API/User/Scim.hs b/libs/wire-api/src/Wire/API/User/Scim.hs index 8ca092eed10..e2115dfdbd7 100644 --- a/libs/wire-api/src/Wire/API/User/Scim.hs +++ b/libs/wire-api/src/Wire/API/User/Scim.hs @@ -70,7 +70,7 @@ import Imports import SAML2.WebSSO qualified as SAML import SAML2.WebSSO.Test.Arbitrary () import Servant.API (FromHttpApiData (..), ToHttpApiData (..)) -import Test.QuickCheck (Gen) +import Test.QuickCheck (Gen, elements) import Test.QuickCheck qualified as QC import Web.HttpApiData (parseHeaderWithPrefix) import Web.Scim.AttrName (AttrName (..)) @@ -358,7 +358,15 @@ data ValidScimUser = ValidScimUser { externalId :: ValidScimId, handle :: Handle, name :: BT.Name, - emails :: [EmailAddress], + -- | The (at most one) email address Wire stores for this user: brig keeps a + -- single email, so the SCIM @emails@ list is reduced to one entry by + -- @scimEmailsToEmail@ (the entry marked @primary@, else the + -- first). 'emailType' and 'emailPrimary' are the @type@\/@primary@ + -- metadata of that same single entry ('Nothing' = not supplied, echoed as + -- absent). + emails :: Maybe EmailAddress, + emailType :: Maybe Text, + emailPrimary :: Maybe Bool, richInfo :: RI.RichInfo, active :: Bool, locale :: Maybe Locale, @@ -515,3 +523,9 @@ newtype ScimTokenName = ScimTokenName {fromScimTokenName :: Text} instance ToSchema ScimTokenName where schema = object $ ScimTokenName <$> fromScimTokenName .= field "name" schema + +data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated + deriving (Eq, Show, Generic) + +instance Arbitrary ScimUserCreationStatus where + arbitrary = elements [ScimUserCreating, ScimUserCreated] diff --git a/libs/wire-api/src/Wire/API/VersionInfo.hs b/libs/wire-api/src/Wire/API/VersionInfo.hs index 925e489859f..45cdcda45f5 100644 --- a/libs/wire-api/src/Wire/API/VersionInfo.hs +++ b/libs/wire-api/src/Wire/API/VersionInfo.hs @@ -130,6 +130,13 @@ instance instance (RoutesToPaths api) => RoutesToPaths (Until v :> api) where getRoutes = getRoutes @api +-- | 'Until' is transparent for OpenAPI generation: version gating is a runtime +-- concern, and a non-version-specific doc (e.g. the internal API) renders the +-- inner endpoint. Per-version docs go through 'SpecialiseToVersion', which +-- strips 'Until'\/'From' and never reaches this instance. +instance (HasOpenApi api) => HasOpenApi (Until v :> api) where + toOpenApi _ = toOpenApi (Proxy @api) + instance ( SingI n, Ord (Demote v), @@ -172,6 +179,11 @@ instance instance (RoutesToPaths api) => RoutesToPaths (From v :> api) where getRoutes = getRoutes @api +-- | Same as the 'Until' instance above: a doc no-op for non-version-specific +-- docs (e.g. the internal API). Per-version docs specialise it away. +instance (HasOpenApi api) => HasOpenApi (From v :> api) where + toOpenApi _ = toOpenApi (Proxy @api) + instance (Enum v, HasServer api ctx) => HasServer (APIVersion (v :: Type) :> api) ctx where type ServerT (APIVersion v :> api) m = v -> ServerT api m diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs index 02dc1d21e43..3489a1b257a 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated.hs @@ -679,7 +679,10 @@ tests = (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_13, "testObject_Event_user_13.json"), (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_14, "testObject_Event_user_14.json"), (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_15, "testObject_Event_user_15.json"), - (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_16, "testObject_Event_user_16.json") + (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_16, "testObject_Event_user_16.json"), + (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_17, "testObject_Event_user_17.json"), + (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_18, "testObject_Event_user_18.json"), + (Test.Wire.API.Golden.Generated.Event_user.testObject_Event_user_19, "testObject_Event_user_19.json") ], testGroup "Golden: EventType_user" $ testObjects diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs index 1341f2f6cc7..a6d987ce5e9 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs @@ -412,3 +412,23 @@ testObject_Event_user_17 = } ) ) + +testObject_Event_user_18 :: Event +testObject_Event_user_18 = + Event + (Qualified (Id (fromJust (UUID.fromString "00005d81-0000-0d71-0000-1d8f00007d32"))) (Domain "faraway.example.com")) + Nothing + (EventFromUser (Qualified (Id (fromJust (UUID.fromString "00003b8b-0000-3395-0000-076a00007830"))) (Domain "faraway.example.com"))) + (read "1864-05-22 09:51:07.104 UTC") + (Just (Id (fromJust (UUID.fromString "90eda181-bb05-4525-a5cc-d0038deda9b7")))) + EdConvDeleteMeeting + +testObject_Event_user_19 :: Event +testObject_Event_user_19 = + Event + (Qualified (Id (fromJust (UUID.fromString "00005d81-0000-0d71-0000-1d8f00007d32"))) (Domain "faraway.example.com")) + Nothing + (EventFromUser (Qualified (Id (fromJust (UUID.fromString "00003b8b-0000-3395-0000-076a00007830"))) (Domain "faraway.example.com"))) + (read "1864-05-22 09:51:07.104 UTC") + Nothing + EdConvDeleteMeeting diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs index 6633d2a9e42..99a69b9e932 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UserProfile_user.hs @@ -54,7 +54,8 @@ testObject_UserProfile_user_1 = profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } testObject_UserProfile_user_2 :: UserProfile @@ -92,5 +93,6 @@ testObject_UserProfile_user_2 = { category = Category "other", description = unsafeRange "bloob" }, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index de8aaafc9fb..8d79db07860 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -21,6 +21,7 @@ import Imports import Test.Tasty import Test.Tasty.HUnit import Test.Wire.API.Golden.Manual.Activate_user +import Test.Wire.API.Golden.Manual.AdminlessJobs import Test.Wire.API.Golden.Manual.App import Test.Wire.API.Golden.Manual.CannonId import Test.Wire.API.Golden.Manual.ClientCapability @@ -49,6 +50,9 @@ import Test.Wire.API.Golden.Manual.ListUsersById import Test.Wire.API.Golden.Manual.LoginId_user import Test.Wire.API.Golden.Manual.Login_user import Test.Wire.API.Golden.Manual.MLSKeys +import Test.Wire.API.Golden.Manual.Meeting +import Test.Wire.API.Golden.Manual.MeetingEvent +import Test.Wire.API.Golden.Manual.MlsMigrationConfig import Test.Wire.API.Golden.Manual.Pagination import Test.Wire.API.Golden.Manual.Presence import Test.Wire.API.Golden.Manual.Push @@ -73,6 +77,31 @@ tests = "Manual golden tests" [ testGroup "NewApp" $ testObjects [(testObject_NewApp_1, "testObject_NewApp_1.json")], + testGroup "AdminlessDeletionJob" $ + testObjects + [ (testObject_AdminlessDeletionJob_1, "testObject_AdminlessDeletionJob_1.json"), + (testObject_AdminlessDeletionJob_2, "testObject_AdminlessDeletionJob_2.json") + ], + testGroup "AdminlessReminderJob" $ + testObjects + [ (testObject_AdminlessReminderJob_1, "testObject_AdminlessReminderJob_1.json"), + (testObject_AdminlessReminderJob_2, "testObject_AdminlessReminderJob_2.json") + ], + testGroup "AdminlessSetupJob" $ + testObjects + [ (testObject_AdminlessSetupJob_1, "testObject_AdminlessSetupJob_1.json"), + (testObject_AdminlessSetupJob_2, "testObject_AdminlessSetupJob_2.json") + ], + testGroup "MeetingsJobPayload" $ + testObjects + [ (testObject_MeetingsJobPayload_MeetingsCleanup_1, "testObject_MeetingsJobPayload_MeetingsCleanup_1.json") + ], + testGroup "ConversationsJobPayload" $ + testObjects + [ (testObject_ConversationsJobPayload_AdminlessDeletion_1, "testObject_ConversationsJobPayload_AdminlessDeletion_1.json"), + (testObject_ConversationsJobPayload_AdminlessReminder_1, "testObject_ConversationsJobPayload_AdminlessReminder_1.json"), + (testObject_ConversationsJobPayload_AdminlessSetup_1, "testObject_ConversationsJobPayload_AdminlessSetup_1.json") + ], testGroup "CreatedApp" $ testObjects [(testObject_CreatedApp_1, "testObject_CreatedApp_1.json")], testGroup "AppInfo" $ @@ -135,6 +164,24 @@ tests = (testObject_Event_conversation_manual_2, "testObject_Event_conversation_manual_2.json"), (testObject_Event_conversation_manual_3, "testObject_Event_conversation_manual_3.json") ], + testGroup "MeetingEvent" $ + testObjects + [ (testObject_Event_meeting_create_manual_1, "testObject_Event_meeting_create_manual_1.json"), + (testObject_Event_meeting_update_manual_1, "testObject_Event_meeting_update_manual_1.json"), + (testObject_Event_meeting_delete_manual_1, "testObject_Event_meeting_delete_manual_1.json"), + (testObject_Event_meeting_member_add_manual_1, "testObject_Event_meeting_member_add_manual_1.json"), + (testObject_Event_meeting_member_add_manual_2, "testObject_Event_meeting_member_add_manual_2.json") + ], + testGroup "Meeting V15" $ + testObjects + [ (testObject_MeetingV16_manual_1, "testObject_Meeting_v15_manual_1.json"), + (testObject_MeetingV16_manual_2, "testObject_Meeting_v15_manual_2.json") + ], + testGroup "Meeting" $ + testObjects + [ (testObject_Meeting_manual_1, "testObject_Meeting_manual_1.json"), + (testObject_Meeting_manual_2, "testObject_Meeting_manual_2.json") + ], testGroup "GetPaginatedConversationIds" $ testObjects [ (testObject_GetPaginatedConversationIds_1, "testObject_GetPaginatedConversationIds_1.json"), @@ -304,6 +351,15 @@ tests = testObjects [ (testObject_MLSKeysByPurpose1, "testObject_MLSKeysByPurpose_1.json") ], + testGroup "Feature MlsMigrationConfig" $ + testObjects + [(testObject_MlsMigrationConfig_1, "testObject_MlsMigrationConfig_1.json")], + testGroup "LockableFeature MlsMigrationConfig" $ + testObjects + [(testObject_MlsMigrationConfig_2, "testObject_MlsMigrationConfig_2.json")], + testGroup "LockableFeaturePatch MlsMigrationConfig" $ + testObjects + [(testObject_MlsMigrationConfig_3, "testObject_MlsMigrationConfig_3.json")], testGroup "SendActivationCode" $ testObjects [ (testObject_SendActivationCode_1, "testObject_SendActivationCode_1.json"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs new file mode 100644 index 00000000000..2a498dd8297 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/AdminlessJobs.hs @@ -0,0 +1,69 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.AdminlessJobs where + +import Data.Id +import Data.Json.Util (UTCTimeMillis, readUTCTimeMillis) +import Data.UUID qualified as UUID +import Imports +import Wire.API.Jobs + +teamId :: TeamId +teamId = Id . fromJust $ UUID.fromString "00000000-0000-0000-0000-000000000001" + +conversationId :: ConvId +conversationId = Id . fromJust $ UUID.fromString "00000000-0000-0000-0000-000000000002" + +originUserId :: UserId +originUserId = Id . fromJust $ UUID.fromString "00000000-0000-0000-0000-000000000003" + +requestId :: RequestId +requestId = RequestId "golden-adminless-job" + +deletionScheduledFor :: UTCTimeMillis +deletionScheduledFor = fromJust $ readUTCTimeMillis "2026-07-14T12:00:00.000Z" + +testObject_AdminlessDeletionJob_1 :: AdminlessDeletionJob +testObject_AdminlessDeletionJob_1 = AdminlessDeletionJob teamId conversationId Nothing requestId + +testObject_AdminlessDeletionJob_2 :: AdminlessDeletionJob +testObject_AdminlessDeletionJob_2 = AdminlessDeletionJob teamId conversationId (Just originUserId) requestId + +testObject_AdminlessReminderJob_1 :: AdminlessReminderJob +testObject_AdminlessReminderJob_1 = AdminlessReminderJob teamId conversationId Nothing deletionScheduledFor requestId + +testObject_AdminlessReminderJob_2 :: AdminlessReminderJob +testObject_AdminlessReminderJob_2 = AdminlessReminderJob teamId conversationId (Just originUserId) deletionScheduledFor requestId + +testObject_AdminlessSetupJob_1 :: AdminlessSetupJob +testObject_AdminlessSetupJob_1 = AdminlessSetupJob teamId Nothing requestId + +testObject_AdminlessSetupJob_2 :: AdminlessSetupJob +testObject_AdminlessSetupJob_2 = AdminlessSetupJob teamId (Just originUserId) requestId + +testObject_MeetingsJobPayload_MeetingsCleanup_1 :: MeetingsJobPayload +testObject_MeetingsJobPayload_MeetingsCleanup_1 = MeetingsCleanup MeetingsCleanupJob + +testObject_ConversationsJobPayload_AdminlessDeletion_1 :: ConversationsJobPayload +testObject_ConversationsJobPayload_AdminlessDeletion_1 = AdminlessDeletion testObject_AdminlessDeletionJob_1 + +testObject_ConversationsJobPayload_AdminlessReminder_1 :: ConversationsJobPayload +testObject_ConversationsJobPayload_AdminlessReminder_1 = AdminlessReminder testObject_AdminlessReminderJob_1 + +testObject_ConversationsJobPayload_AdminlessSetup_1 :: ConversationsJobPayload +testObject_ConversationsJobPayload_AdminlessSetup_1 = AdminlessSetup testObject_AdminlessSetupJob_1 diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs index cae4d041bd5..b2f7b3db6ef 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/IdP.hs @@ -1,5 +1,23 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Wire.API.Golden.Manual.IdP where +import Data.Domain (Domain (..)) import Data.Id import Data.List.NonEmpty import Data.UUID @@ -114,7 +132,7 @@ testObject_IdP_1 = ], _replacedBy = Just (IdPId {fromIdPId = (fromJust . Data.UUID.fromString) "fc5f3bf8-c296-69e7-27fd-70d483740fe4"}), _handle = IdPHandle {unIdPHandle = "614c0bb0-1b33-98b6-8600-a1b290bbe1d7"}, - _domain = Just "wire.com" + _domain = Just (Domain "wire.com") } } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs index 34e63641db6..02b3d1ae302 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ListUsersById.hs @@ -56,7 +56,8 @@ profile1 = profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Just (ContactStatus NonContactable) } profile2 = UserProfile @@ -81,7 +82,8 @@ profile2 = { category = Category "other", description = unsafeRange "bloob" }, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } testObject_ListUsersById_user_1 :: ListUsersById diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs new file mode 100644 index 00000000000..ee6c10a5c4e --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/Meeting.hs @@ -0,0 +1,90 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.Meeting where + +import Data.Domain (Domain (..)) +import Data.Id +import Data.Qualified (Qualified (..)) +import Data.Range (unsafeRange) +import Data.Time +import Data.UUID qualified as UUID +import Imports +import Wire.API.Meeting +import Wire.API.User (unsafeEmailAddress) + +testObject_Meeting_manual_1 :: Meeting +testObject_Meeting_manual_1 = + Meeting + { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + title = unsafeRange "Weekly Sync", + creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002")), qDomain = Domain {_domainText = "example.com"}}, + startTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + endTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 3600}, + tzid = defaultLegacyTimeZone, + recurrence = Nothing, + conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000003-0000-0000-0000-000000000003")), qDomain = Domain {_domainText = "example.com"}}, + invitedEmails = [unsafeEmailAddress "someone" "example.com"], + createdAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0}, + updatedAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0} + } + +testObject_Meeting_manual_2 :: Meeting +testObject_Meeting_manual_2 = + Meeting + { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000004-0000-0000-0000-000000000004")), qDomain = Domain {_domainText = "example.com"}}, + title = unsafeRange "Sprint Planning", + creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000005-0000-0000-0000-000000000005")), qDomain = Domain {_domainText = "example.com"}}, + startTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 0}, + endTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 5400}, + tzid = defaultLegacyTimeZone, + recurrence = Just (Recurrence {freq = Weekly, interval = 1, until = Nothing}), + conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000006-0000-0000-0000-000000000006")), qDomain = Domain {_domainText = "example.com"}}, + invitedEmails = [], + createdAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + updatedAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0} + } + +testObject_MeetingV16_manual_1 :: MeetingV16 +testObject_MeetingV16_manual_1 = + MeetingV16 + { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + title = unsafeRange "Weekly Sync", + creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002")), qDomain = Domain {_domainText = "example.com"}}, + startTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + endTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 3600}, + recurrence = Nothing, + conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000003-0000-0000-0000-000000000003")), qDomain = Domain {_domainText = "example.com"}}, + invitedEmails = [unsafeEmailAddress "someone" "example.com"], + createdAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0}, + updatedAt = UTCTime {utctDay = ModifiedJulianDay 58118, utctDayTime = 0} + } + +testObject_MeetingV16_manual_2 :: MeetingV16 +testObject_MeetingV16_manual_2 = + MeetingV16 + { id = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000004-0000-0000-0000-000000000004")), qDomain = Domain {_domainText = "example.com"}}, + title = unsafeRange "Sprint Planning", + creator = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000005-0000-0000-0000-000000000005")), qDomain = Domain {_domainText = "example.com"}}, + startTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 0}, + endTime = UTCTime {utctDay = ModifiedJulianDay 58120, utctDayTime = 5400}, + recurrence = Just (Recurrence {freq = Weekly, interval = 1, until = Nothing}), + conversationId = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000006-0000-0000-0000-000000000006")), qDomain = Domain {_domainText = "example.com"}}, + invitedEmails = [], + createdAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + updatedAt = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0} + } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs new file mode 100644 index 00000000000..3d0e985b8be --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MeetingEvent.hs @@ -0,0 +1,81 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.MeetingEvent where + +import Data.Domain (Domain (..)) +import Data.Id +import Data.Qualified (Qualified (..)) +import Data.Time +import Data.UUID qualified as UUID +import Imports +import Wire.API.Event.Meeting + +testObject_Event_meeting_create_manual_1 :: Event +testObject_Event_meeting_create_manual_1 = + Event + { evtType = Create, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } + +testObject_Event_meeting_update_manual_1 :: Event +testObject_Event_meeting_update_manual_1 = + Event + { evtType = Update, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } + +testObject_Event_meeting_delete_manual_1 :: Event +testObject_Event_meeting_delete_manual_1 = + Event + { evtType = Delete, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } + +testObject_Event_meeting_member_add_manual_1 :: Event +testObject_Event_meeting_member_add_manual_1 = + Event + { evtType = MemberAdd, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Nothing + } + +testObject_Event_meeting_member_add_manual_2 :: Event +testObject_Event_meeting_member_add_manual_2 = + Event + { evtType = MemberAdd, + evtMeeting = Qualified {qUnqualified = Id (fromJust (UUID.fromString "00000001-0000-0000-0000-000000000001")), qDomain = Domain {_domainText = "example.com"}}, + evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtFrom = EventFromUser $ Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtTeam = Just (Id (fromJust (UUID.fromString "00000002-0000-0000-0000-000000000002"))) + } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs new file mode 100644 index 00000000000..793baa173c9 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/MlsMigrationConfig.hs @@ -0,0 +1,54 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.MlsMigrationConfig where + +import Data.Time +import Imports +import Wire.API.Team.Feature + +testObject_MlsMigrationConfig_1 :: Feature MlsMigrationConfig +testObject_MlsMigrationConfig_1 = + Feature + FeatureStatusEnabled + ( MlsMigrationConfig + (Just (UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0})) + (Just (UTCTime {utctDay = ModifiedJulianDay 58200, utctDayTime = 0})) + True + ) + +testObject_MlsMigrationConfig_2 :: LockableFeature MlsMigrationConfig +testObject_MlsMigrationConfig_2 = + LockableFeature + { status = FeatureStatusEnabled, + lockStatus = LockStatusUnlocked, + config = MlsMigrationConfig Nothing Nothing False + } + +testObject_MlsMigrationConfig_3 :: LockableFeaturePatch MlsMigrationConfig +testObject_MlsMigrationConfig_3 = + LockableFeaturePatch + { status = Just FeatureStatusEnabled, + lockStatus = Nothing, + config = + Just + ( MlsMigrationConfig + Nothing + (Just (UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0})) + False + ) + } diff --git a/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_1.json b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_1.json new file mode 100644 index 00000000000..1fc08afe734 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_1.json @@ -0,0 +1,5 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_2.json b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_2.json new file mode 100644 index 00000000000..1c007054945 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessDeletionJob_2.json @@ -0,0 +1,6 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "orig_user_id": "00000000-0000-0000-0000-000000000003", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessReminderJob_1.json b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_1.json new file mode 100644 index 00000000000..786f5d358f0 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_1.json @@ -0,0 +1,6 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "deletion_scheduled_for": "2026-07-14T12:00:00.000Z", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessReminderJob_2.json b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_2.json new file mode 100644 index 00000000000..fb6056b3fcf --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessReminderJob_2.json @@ -0,0 +1,7 @@ +{ + "conversation_id": "00000000-0000-0000-0000-000000000002", + "deletion_scheduled_for": "2026-07-14T12:00:00.000Z", + "orig_user_id": "00000000-0000-0000-0000-000000000003", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessSetupJob_1.json b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_1.json new file mode 100644 index 00000000000..e9b075e3423 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_1.json @@ -0,0 +1,4 @@ +{ + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_AdminlessSetupJob_2.json b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_2.json new file mode 100644 index 00000000000..75e75341061 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_AdminlessSetupJob_2.json @@ -0,0 +1,5 @@ +{ + "orig_user_id": "00000000-0000-0000-0000-000000000003", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" +} diff --git a/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessDeletion_1.json b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessDeletion_1.json new file mode 100644 index 00000000000..1a66a9225db --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessDeletion_1.json @@ -0,0 +1,8 @@ +{ + "data": { + "conversation_id": "00000000-0000-0000-0000-000000000002", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" + }, + "type": "adminless_deletion" +} diff --git a/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessReminder_1.json b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessReminder_1.json new file mode 100644 index 00000000000..9d6b2b9eea7 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessReminder_1.json @@ -0,0 +1,9 @@ +{ + "data": { + "conversation_id": "00000000-0000-0000-0000-000000000002", + "deletion_scheduled_for": "2026-07-14T12:00:00.000Z", + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" + }, + "type": "adminless_reminder" +} diff --git a/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessSetup_1.json b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessSetup_1.json new file mode 100644 index 00000000000..0f074f2d34b --- /dev/null +++ b/libs/wire-api/test/golden/testObject_ConversationsJobPayload_AdminlessSetup_1.json @@ -0,0 +1,7 @@ +{ + "data": { + "request_id": "golden-adminless-job", + "team_id": "00000000-0000-0000-0000-000000000001" + }, + "type": "adminless_setup" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json new file mode 100644 index 00000000000..faaf77d4b6a --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_create_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.create", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json new file mode 100644 index 00000000000..5bae8ab62d0 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_delete_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.delete", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json new file mode 100644 index 00000000000..8d40ebe09a2 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.member-add", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json new file mode 100644 index 00000000000..628f1bf141e --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_member_add_manual_2.json @@ -0,0 +1,20 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "team": "00000002-0000-0000-0000-000000000002", + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.member-add", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json new file mode 100644 index 00000000000..e1754d221ef --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_meeting_update_manual_1.json @@ -0,0 +1,19 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "meeting.update", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_user_17.json b/libs/wire-api/test/golden/testObject_Event_user_17.json new file mode 100644 index 00000000000..8ae5d494723 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_user_17.json @@ -0,0 +1,76 @@ +{ + "conversation": "00007d9a-0000-4f23-0000-2b8a000057c1", + "data": { + "access": [ + "private", + "invite" + ], + "access_role": "activated", + "access_role_v2": [ + "team_member", + "non_team_member" + ], + "add_permission": null, + "cells_state": "disabled", + "creator": "00000000-0000-0000-0000-000400000001", + "group_conv_type": "meeting", + "history": null, + "id": "00000000-0000-0000-0000-000300000001", + "last_event": "0.0", + "last_event_time": "1970-01-01T00:00:00.000Z", + "members": { + "others": [ + { + "conversation_role": "wire_member", + "id": "00000000-0000-0006-0000-000100000001", + "qualified_id": { + "domain": "golden.example.com", + "id": "00000000-0000-0006-0000-000100000001" + }, + "status": 0 + } + ], + "self": { + "conversation_role": "wire_admin", + "hidden": false, + "hidden_ref": null, + "id": "00000002-0000-0000-0000-000000000001", + "otr_archived": false, + "otr_archived_ref": null, + "otr_muted_ref": null, + "otr_muted_status": null, + "qualified_id": { + "domain": "golden.example.com", + "id": "00000002-0000-0000-0000-000000000001" + }, + "service": null, + "status": 0, + "status_ref": "0.0", + "status_time": "1970-01-01T00:00:00.000Z" + } + }, + "message_timer": null, + "name": "Meeting Room", + "parent": null, + "protocol": "proteus", + "qualified_id": { + "domain": "golden.example.com", + "id": "00000000-0000-0000-0000-000300000001" + }, + "receipt_mode": null, + "team": "00000000-0000-0005-0000-000100000001", + "type": 0 + }, + "from": "00005c6b-0000-6a17-0000-3e5b00006e2f", + "qualified_conversation": { + "domain": "faraway.example.com", + "id": "00007d9a-0000-4f23-0000-2b8a000057c1" + }, + "qualified_from": { + "domain": "faraway.example.com", + "id": "00005c6b-0000-6a17-0000-3e5b00006e2f" + }, + "time": "1864-05-20T12:14:33.001Z", + "type": "conversation.create-meeting", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_user_18.json b/libs/wire-api/test/golden/testObject_Event_user_18.json new file mode 100644 index 00000000000..0698e464233 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_user_18.json @@ -0,0 +1,17 @@ +{ + "conversation": "00005d81-0000-0d71-0000-1d8f00007d32", + "data": null, + "from": "00003b8b-0000-3395-0000-076a00007830", + "qualified_conversation": { + "domain": "faraway.example.com", + "id": "00005d81-0000-0d71-0000-1d8f00007d32" + }, + "qualified_from": { + "domain": "faraway.example.com", + "id": "00003b8b-0000-3395-0000-076a00007830" + }, + "team": "90eda181-bb05-4525-a5cc-d0038deda9b7", + "time": "1864-05-22T09:51:07.104Z", + "type": "conversation.delete-meeting", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_Event_user_19.json b/libs/wire-api/test/golden/testObject_Event_user_19.json new file mode 100644 index 00000000000..e9d22fcbd6e --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_user_19.json @@ -0,0 +1,16 @@ +{ + "conversation": "00005d81-0000-0d71-0000-1d8f00007d32", + "data": null, + "from": "00003b8b-0000-3395-0000-076a00007830", + "qualified_conversation": { + "domain": "faraway.example.com", + "id": "00005d81-0000-0d71-0000-1d8f00007d32" + }, + "qualified_from": { + "domain": "faraway.example.com", + "id": "00003b8b-0000-3395-0000-076a00007830" + }, + "time": "1864-05-22T09:51:07.104Z", + "type": "conversation.delete-meeting", + "via": "user" +} diff --git a/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json b/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json index 77b0f024dc8..dc3639cc1b8 100644 --- a/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json +++ b/libs/wire-api/test/golden/testObject_ListUsersById_user_2.json @@ -3,6 +3,9 @@ { "accent_id": 0, "assets": [], + "contact_status": { + "state": "non-contactable" + }, "id": "4f201a43-935e-4e19-8fe0-0a878d3d6e74", "legalhold_status": "disabled", "name": "user1", diff --git a/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json b/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json index 134ec75cf61..a708c79c97e 100644 --- a/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json +++ b/libs/wire-api/test/golden/testObject_ListUsersById_user_3.json @@ -9,6 +9,9 @@ { "accent_id": 0, "assets": [], + "contact_status": { + "state": "non-contactable" + }, "id": "4f201a43-935e-4e19-8fe0-0a878d3d6e74", "legalhold_status": "disabled", "name": "user1", diff --git a/libs/wire-api/test/golden/testObject_Meeting_manual_1.json b/libs/wire-api/test/golden/testObject_Meeting_manual_1.json new file mode 100644 index 00000000000..4e64a10d6f3 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_manual_1.json @@ -0,0 +1,23 @@ +{ + "created_at": "2017-12-31T00:00:00Z", + "end_time": "2018-01-01T01:00:00Z", + "invited_emails": [ + "someone@example.com" + ], + "qualified_conversation": { + "domain": "example.com", + "id": "00000003-0000-0000-0000-000000000003" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000002-0000-0000-0000-000000000002" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "start_time": "2018-01-01T00:00:00Z", + "title": "Weekly Sync", + "tzid": "Europe/Berlin", + "updated_at": "2017-12-31T00:00:00Z" +} diff --git a/libs/wire-api/test/golden/testObject_Meeting_manual_2.json b/libs/wire-api/test/golden/testObject_Meeting_manual_2.json new file mode 100644 index 00000000000..347bc45f850 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_manual_2.json @@ -0,0 +1,25 @@ +{ + "created_at": "2018-01-01T00:00:00Z", + "end_time": "2018-01-02T01:30:00Z", + "invited_emails": [], + "qualified_conversation": { + "domain": "example.com", + "id": "00000006-0000-0000-0000-000000000006" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000005-0000-0000-0000-000000000005" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000004-0000-0000-0000-000000000004" + }, + "recurrence": { + "frequency": "weekly", + "interval": 1 + }, + "start_time": "2018-01-02T00:00:00Z", + "title": "Sprint Planning", + "tzid": "Europe/Berlin", + "updated_at": "2018-01-01T00:00:00Z" +} diff --git a/libs/wire-api/test/golden/testObject_Meeting_v15_manual_1.json b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_1.json new file mode 100644 index 00000000000..3f41ec482af --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_1.json @@ -0,0 +1,23 @@ +{ + "created_at": "2017-12-31T00:00:00Z", + "end_time": "2018-01-01T01:00:00Z", + "invited_emails": [ + "someone@example.com" + ], + "qualified_conversation": { + "domain": "example.com", + "id": "00000003-0000-0000-0000-000000000003" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000002-0000-0000-0000-000000000002" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000001-0000-0000-0000-000000000001" + }, + "start_time": "2018-01-01T00:00:00Z", + "title": "Weekly Sync", + "trial": false, + "updated_at": "2017-12-31T00:00:00Z" +} diff --git a/libs/wire-api/test/golden/testObject_Meeting_v15_manual_2.json b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_2.json new file mode 100644 index 00000000000..caf3d890480 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Meeting_v15_manual_2.json @@ -0,0 +1,25 @@ +{ + "created_at": "2018-01-01T00:00:00Z", + "end_time": "2018-01-02T01:30:00Z", + "invited_emails": [], + "qualified_conversation": { + "domain": "example.com", + "id": "00000006-0000-0000-0000-000000000006" + }, + "qualified_creator": { + "domain": "example.com", + "id": "00000005-0000-0000-0000-000000000005" + }, + "qualified_id": { + "domain": "example.com", + "id": "00000004-0000-0000-0000-000000000004" + }, + "recurrence": { + "frequency": "weekly", + "interval": 1 + }, + "start_time": "2018-01-02T00:00:00Z", + "title": "Sprint Planning", + "trial": false, + "updated_at": "2018-01-01T00:00:00Z" +} diff --git a/libs/wire-api/test/golden/testObject_MeetingsJobPayload_MeetingsCleanup_1.json b/libs/wire-api/test/golden/testObject_MeetingsJobPayload_MeetingsCleanup_1.json new file mode 100644 index 00000000000..550e044ca10 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MeetingsJobPayload_MeetingsCleanup_1.json @@ -0,0 +1,4 @@ +{ + "data": {}, + "type": "meetings_cleanup" +} diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json new file mode 100644 index 00000000000..a83c41d54a5 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_1.json @@ -0,0 +1,9 @@ +{ + "config": { + "allowManualMigration": true, + "finaliseRegardlessAfter": "2018-03-23T00:00:00Z", + "startTime": "2018-01-01T00:00:00Z" + }, + "status": "enabled", + "ttl": "unlimited" +} diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json new file mode 100644 index 00000000000..6014232db55 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_2.json @@ -0,0 +1,8 @@ +{ + "config": { + "allowManualMigration": false + }, + "lockStatus": "unlocked", + "status": "enabled", + "ttl": "unlimited" +} diff --git a/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json new file mode 100644 index 00000000000..eba2ce56578 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_MlsMigrationConfig_3.json @@ -0,0 +1,8 @@ +{ + "config": { + "allowManualMigration": false, + "finaliseRegardlessAfter": "2018-01-01T00:00:00Z" + }, + "status": "enabled", + "ttl": "unlimited" +} diff --git a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs index aa9527a3e69..7362006272b 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Conversation.hs @@ -72,9 +72,11 @@ testIsCellsConversationEvent = ConvCreate -> isCellsConversationEvent e === True ConvCreateMeeting -> isCellsConversationEvent e === True ConvDelete -> isCellsConversationEvent e === True + ConvDeleteMeeting -> isCellsConversationEvent e === True ConvReset -> isCellsConversationEvent e === False ConvMessageTimerUpdate -> isCellsConversationEvent e === False ConvHistoryUpdate -> isCellsConversationEvent e === False + ConvAdminlessReminder -> isCellsConversationEvent e === False ConvReceiptModeUpdate -> isCellsConversationEvent e === False ConvRename -> isCellsConversationEvent e === True MemberJoin -> isCellsConversationEvent e === True diff --git a/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs b/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs new file mode 100644 index 00000000000..63a929f2bf4 --- /dev/null +++ b/libs/wire-api/test/unit/Test/Wire/API/Meeting.hs @@ -0,0 +1,58 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +{-# LANGUAGE OverloadedRecordDot #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Meeting where + +import Test.Tasty +import Test.Tasty.QuickCheck (Property, testProperty, (===)) +import Wire.API.Meeting + +tests :: TestTree +tests = + testGroup + "Meeting" + [ testProperty "toLegacy . fromLegacy === id (V16)" toLegacyFromLegacy, + testProperty "fromLegacy . toLegacy === id (V17)" fromLegacyToLegacy + ] + +-- | V16->V17->V16 round-trips: @end_time@ (the source of truth) is preserved +-- verbatim, so the legacy shape is recovered exactly. +toLegacyFromLegacy :: TimeZone -> MeetingV16 -> Property +toLegacyFromLegacy tz lm = toLegacy (fromLegacy tz lm) === lm + +-- | V17->V16->V17 round-trips when the injected @tzid@ matches the original; +-- @end_time@ is preserved, so all non-tzid fields are recovered exactly. +fromLegacyToLegacy :: Meeting -> Property +fromLegacyToLegacy m = fromLegacy m.tzid (toLegacy m) === m diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index ab1c1aea3c6..14f6f83c610 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -17,13 +17,17 @@ module Test.Wire.API.Roundtrip.Aeson (tests) where -import Data.Aeson (FromJSON, ToJSON, parseJSON, toJSON) +import Data.Aeson (FromJSON, Result (..), ToJSON, Value (..), fromJSON, parseJSON, toJSON) +import Data.Aeson.Key qualified as Key +import Data.Aeson.KeyMap qualified as KeyMap import Data.Aeson.Types (parseEither) +import Data.Default (def) import Data.Id (ConvId) import Data.Misc import Data.OpenApi (ToSchema, validatePrettyToJSON) import Imports import Test.Tasty qualified as T +import Test.Tasty.HUnit (assertEqual, assertFailure, testCase) import Test.Tasty.QuickCheck (Arbitrary, counterexample, testProperty, (.&&.), (===)) import Type.Reflection (typeRep) import Wire.API.Asset qualified as Asset @@ -42,7 +46,9 @@ import Wire.API.Event.Conversation qualified as Event.Conversation import Wire.API.Event.Team qualified as Event.Team import Wire.API.Event.WebSocketProtocol qualified as EventWebSocketProtocol import Wire.API.FederationStatus qualified as FederationStatus +import Wire.API.Jobs qualified as Jobs import Wire.API.Locale qualified as Locale +import Wire.API.Meeting qualified as Meeting import Wire.API.Message qualified as Message import Wire.API.OAuth qualified as OAuth import Wire.API.Properties qualified as Properties @@ -60,6 +66,7 @@ import Wire.API.SystemSettings qualified as SystemSettings import Wire.API.Team qualified as Team import Wire.API.Team.Conversation qualified as Team.Conversation import Wire.API.Team.Feature qualified as Team.Feature +import Wire.API.Team.FeatureFlags qualified as Team.FeatureFlags import Wire.API.Team.Invitation qualified as Team.Invitation import Wire.API.Team.LegalHold qualified as Team.LegalHold import Wire.API.Team.LegalHold.External qualified as Team.LegalHold.External @@ -153,9 +160,17 @@ tests = testRoundTrip @Conversation.Role.ConversationRolesList, testRoundTrip @Conversation.Typing.TypingStatus, testRoundTrip @CustomBackend.CustomBackend, + testRoundTrip @Jobs.MeetingsCleanupJob, + testRoundTripWithSwagger @Jobs.AdminlessDeletionJob, + testRoundTripWithSwagger @Jobs.AdminlessReminderJob, + testRoundTripWithSwagger @Jobs.AdminlessSetupJob, + testRoundTrip @Jobs.MeetingsJobPayload, + testRoundTrip @Jobs.ConversationsJobPayload, testRoundTrip @EJPD.EJPDContact, testRoundTrip @Event.Conversation.Event, testRoundTrip @Event.Conversation.EventType, + testRoundTrip @Event.Conversation.SystemEvent, + testRoundTrip @Event.Conversation.SystemEventType, testRoundTrip @Event.Conversation.SimpleMember, testRoundTrip @Event.Conversation.MembersJoin, testRoundTrip @Event.Conversation.Connect, @@ -369,7 +384,7 @@ tests = testRoundTrip @TeamsIntra.TeamStatusUpdate, testRoundTrip @TeamsIntra.TeamData, testRoundTrip @TeamsIntra.TeamName, - testRoundTrip @BackgroundJobs.Job, + testRoundTrip @BackgroundJobs.BackgroundJob, testRoundTrip @User.ManagedByUpdate, testRoundTrip @User.Auth.ReAuthUser, testRoundTrip @User.RichInfoUpdate, @@ -379,9 +394,23 @@ tests = testRoundTrip @User.UpdateConnectionsInternal, testRoundTrip @Team.TeamSize, testRoundTrip @Team.LegalHold.Internal.LegalHoldService, - testRoundTrip @Team.LegalHold.Internal.LegalHoldClientRequest + testRoundTrip @Team.LegalHold.Internal.LegalHoldClientRequest, + meetingTrialVersioningTests, + testRoundTripWithSwagger @Meeting.Meeting, + testRoundTripWithSwagger @Meeting.MeetingV16, + testRoundTripWithSwagger @[Meeting.MeetingV16], + testFeatureFlagsCanonicalJsonRoundtrip ] +testFeatureFlagsCanonicalJsonRoundtrip :: T.TestTree +testFeatureFlagsCanonicalJsonRoundtrip = + testCase "FeatureFlags accepts its canonical JSON representation" $ + case fromJSON (toJSON expected) of + Error err -> assertFailure $ "Could not decode canonical FeatureFlags JSON: " <> err + Success actual -> assertEqual "Roundtrip result should be the same as the original" actual expected + where + expected = def @Team.FeatureFlags.FeatureFlags + testRoundTrip :: forall a. (Arbitrary a, Typeable a, ToJSON a, FromJSON a, Eq a, Show a) => @@ -418,3 +447,26 @@ testRoundTripWithSwagger = testProperty msg (trip .&&. scm) validatePrettyToJSON v ) $ isNothing (validatePrettyToJSON v) + +-- | Defends the API versioning contract introduced when dropping the deprecated +-- @trial@ field: legacy versions (< V17) still render @trial@ as @false@, while +-- the current version (V17) omits it entirely. +meetingTrialVersioningTests :: T.TestTree +meetingTrialVersioningTests = + T.testGroup + "Meeting trial field versioning" + [ testProperty "legacy (V15) response renders trial=false" $ + \(m :: Meeting.MeetingV16) -> + trialField (toJSON m) === Just False, + testProperty "current (V17) response omits trial" $ + \(m :: Meeting.Meeting) -> + trialField (toJSON m) === Nothing + ] + +-- | Extract the @trial@ boolean from a 'Meeting' JSON object, if present. +trialField :: Value -> Maybe Bool +trialField = \case + Object o -> case KeyMap.lookup (Key.fromString "trial") o of + Just (Bool b) -> Just b + _ -> Nothing + _ -> Nothing diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs index 191f55bffd0..532ff0510ed 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/PostgresMarshall.hs @@ -16,6 +16,23 @@ -- with this program. If not, see . {-# OPTIONS_GHC -Wno-orphans #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Wire.API.Roundtrip.PostgresMarshall (tests) where import Crypto.Error (CryptoFailable (..)) diff --git a/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs b/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs new file mode 100644 index 00000000000..0400bc3a9a6 --- /dev/null +++ b/libs/wire-api/test/unit/Test/Wire/API/Routes/OAuthScopes.hs @@ -0,0 +1,306 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Two independent places declare which OAuth scope an endpoint needs, and +-- nothing keeps them in sync: +-- +-- 1. @charts/nginz/values.yaml@ -- @oauth_scope:@ on an upstream entry. This is +-- what is actually /enforced/: nginz rejects OAuth tokens without the scope. +-- 2. The servant routing tables -- 'Wire.API.Routes.Public.DescriptionOAuthScope'. +-- This is only /documentation/: it appends a line to the endpoint description +-- in the swagger docs and has no effect on request handling. +-- +-- Forgetting (2) while doing (1) -- or, more commonly, adding a new version of an +-- endpoint that is already covered by (1) and not carrying the annotation over -- +-- silently produces endpoints that reject OAuth tokens for a scope documented +-- nowhere. This module compares the two for the development version, which is +-- the only one still assembled from the routing tables. +module Test.Wire.API.Routes.OAuthScopes (tests) where + +import Data.Aeson qualified as A +import Data.Aeson.Key qualified as Key +import Data.Aeson.KeyMap qualified as KeyMap +import Data.ByteString.Conversion (toByteString') +import Data.FileEmbed (embedFile, makeRelativeToProject) +import Data.Map qualified as Map +import Data.Set qualified as Set +import Data.Text qualified as T +import Data.Text.Encoding qualified as T +import Data.Yaml qualified as Yaml +import Imports +import Language.Haskell.TH (runIO) +import Servant.API (toUrlPiece) +import Test.Tasty +import Test.Tasty.HUnit +import Text.Regex.TDFA ((=~)) +import Wire.API.OAuth (OAuthScope) +import Wire.API.Routes.Public (renderOAuthScope) +import Wire.API.Routes.Public.Swagger (devVersion, devVersionSwagger) +import Wire.API.Routes.Version + +tests :: TestTree +tests = + testGroup + "OAuth scopes (charts/nginz/values.yaml vs. swagger docs)" + [ testCase "nginz path patterns avoid PCRE-only constructs" testPatternVocabulary, + testCase "every nginz oauth_scope names a real scope" testScopeNamesAreReal, + testCase "enforced scopes and documented scopes agree" testScopesAgree + ] + +-------------------------------------------------------------------------------- +-- what nginz enforces + +-- | The locations nginz emits, in the order it emits them. +-- +-- @charts/nginz/templates/_helpers.tpl@ merges @upstreams@ (minus +-- @ignored_upstreams@) with the enabled @extra_upstreams@ into a single map, and +-- @templates/conf/_nginx.conf.tpl@ ranges over that map. Go template map +-- iteration is sorted by key, so upstreams are emitted alphabetically and only +-- the list within one upstream keeps its document order -- which is exactly what +-- decoding into a 'Map' and taking 'Map.elems' gives us. +newtype NginzLocations = NginzLocations [Location] + +data Location = Location + { locPattern :: Text, + locScope :: Maybe Text + } + +-- | The scope an OAuth token needs to get past nginz to this endpoint. +-- +-- libzauth accepts a whole tier range per method, so a token holding +-- @admin:meetings@ may also @POST@. Documenting every accepted scope would be +-- noise, and would force @POST@ to be annotated with both @write:@ and +-- @admin:@; what the docs should name is the /least/ privilege that suffices, +-- so we take the lowest tier that is actually grantable. +-- +-- Empty when nginz requires no scope, and also when no tier it would accept is +-- in 'Wire.API.OAuth.OAuthScope' -- then the endpoint cannot be reached with an +-- OAuth token at all and there is nothing to document. Mistyped scope names +-- are caught by 'testScopeNamesAreReal'. +-- +-- FUTUREWORK(fisx): https://wearezeta.atlassian.net/browse/WPB-28193 +enforcedScopes :: Text -> Text -> Set Text +enforcedScopes method path = + maybe Set.empty Set.singleton $ do + loc <- find (`locationMatches` path) nginzLocations + base <- locScope loc + find (`Set.member` grantableScopes) [tier <> ":" <> base | tier <- methodScopeTiers method] + +nginzLocations :: [Location] +nginzLocations = + case Yaml.decodeEither' nginzValues of + Left e -> error $ "charts/nginz/values.yaml: " <> Yaml.prettyPrintParseException e + Right (NginzLocations ls) -> ls + +-- | @charts\/nginz\/values.yaml@, embedded at compile time. +-- +-- Under nix only this package's own directory is copied into the build sandbox, +-- so @nix\/wire-server.nix@ splices the chart into @test\/unit\/generated\/@ and +-- we prefer that copy; a plain cabal build has the whole repository checked out +-- and reads the real file instead. The lookup is inline because a top-level +-- splice cannot call a function defined in the same module. +nginzValues :: ByteString +nginzValues = + $( do + spliced <- makeRelativeToProject "test/unit/generated/nginz-values.yaml" + spliced' <- runIO (doesFileExist spliced) + embedFile + =<< if spliced' + then pure spliced + else makeRelativeToProject "../../charts/nginz/values.yaml" + ) + +instance A.FromJSON NginzLocations where + parseJSON = A.withObject "charts/nginz/values.yaml" $ \top -> do + conf <- top A..: "nginx_conf" + ups <- conf A..: "upstreams" + extra <- conf A..:? "extra_upstreams" A..!= Map.empty + ignored <- conf A..:? "ignored_upstreams" A..!= [] + enabled <- conf A..:? "enabled_extra_upstreams" A..!= [] + pure + . NginzLocations + . concat + . Map.elems + $ Map.withoutKeys ups (Set.fromList (ignored :: [Text])) + <> Map.restrictKeys extra (Set.fromList (enabled :: [Text])) + +instance A.FromJSON Location where + parseJSON = A.withObject "nginz upstream entry" $ \o -> + Location <$> o A..: "path" <*> o A..:? "oauth_scope" + +-- | Does this location capture that path? nginx anchors regex locations at the +-- start of the URI but not at the end, so a pattern without a trailing @$@ +-- matches every path with that prefix. +-- +-- The patterns are PCRE (that is what nginx uses) and we match them with +-- regex-tdfa, which is POSIX ERE. The two agree on the handful of constructs +-- values.yaml actually uses; 'testPatternVocabulary' keeps it that way. +locationMatches :: Location -> Text -> Bool +locationMatches loc path = + T.unpack (probePath path) =~ T.unpack ("^" <> locPattern loc) + +-- | @/conversations/{cnv}/code@ becomes @/conversations/PARAM/code@: the literal +-- segments still have to match, the captures must not. +probePath :: Text -> Text +probePath t = + let (before, rest) = T.breakOn "{" t + in if T.null rest + then before + else before <> "PARAM" <> probePath (T.drop 1 (T.dropWhile (/= '}') rest)) + +pcreOnlyConstructs :: [Text] +pcreOnlyConstructs = ["(?", "\\", "{", "*?", "+?"] + +-- | @oauth_scope: foo@ in values.yaml names a scope without a tier; libzauth +-- decides which tiers satisfy it from the request method. See @verify_scope@ in +-- @libs/libzauth/libzauth/src/oauth.rs@. Listed in increasing order of +-- privilege: 'enforcedScopes' takes the first grantable one, so this order +-- decides which scope an endpoint gets documented with. +methodScopeTiers :: Text -> [Text] +methodScopeTiers = \case + "GET" -> ["read", "write", "admin"] + "POST" -> ["write", "admin"] + "PUT" -> ["write", "admin"] + "DELETE" -> ["admin"] + _ -> [] + +-------------------------------------------------------------------------------- +-- what the swagger docs claim + +documentedScopes :: Text -> Set Text +documentedScopes descr = + Set.fromList + [ T.decodeUtf8 (toByteString' scope) + | scope <- [minBound .. maxBound] :: [OAuthScope], + -- Recognise a documented scope by the very string + -- 'renderOAuthScope' produces, so that the two cannot drift + -- apart. + renderOAuthScope scope `T.isInfixOf` descr + ] + +httpMethods :: [Text] +httpMethods = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"] + +-- | @(path, method, description)@ for every operation in a swagger document. +operations :: A.Value -> [(Text, Text, Text)] +operations doc = do + paths <- maybeToList (object doc >>= KeyMap.lookup "paths" >>= object) + (path, pathItem) <- KeyMap.toList paths + item <- maybeToList (object pathItem) + (method, op) <- KeyMap.toList item + let method' = T.toUpper (Key.toText method) + guard (method' `elem` httpMethods) + pure (Key.toText path, method', fromMaybe "" (object op >>= KeyMap.lookup "description" >>= string)) + where + object = \case A.Object o -> Just o; _ -> Nothing + string = \case A.String s -> Just s; _ -> Nothing + +-------------------------------------------------------------------------------- +-- the comparison + +-- | 'Finding's are interesting iff @fEnforced /= fDocumented@. +data Finding = Finding + { fVersion :: Version, + fMethod :: Text, + fPath :: Text, + fEnforced :: Set Text, + fDocumented :: Set Text + } + +-- | The scopes brig can actually issue. Anything else is not a scope at all: +-- 'Wire.API.OAuth.OAuthScopes' fails to parse it, and yields the empty scope set +-- for the whole request. +grantableScopes :: Set Text +grantableScopes = + Set.fromList [T.decodeUtf8 (toByteString' s) | s <- [minBound .. maxBound] :: [OAuthScope]] + +renderFinding :: Finding -> Text +renderFinding f = + T.intercalate + "\t" + [ toUrlPiece (fVersion f), + fMethod f, + fPath f, + T.pack . show . toList $ fEnforced f, + T.pack . show . toList $ fDocumented f + ] + +findings :: [Finding] +findings = + [ Finding devVersion method path enforced documented + | (path, method, descr) <- operations (A.toJSON devVersionSwagger), + let enforced = enforcedScopes method path, + let documented = documentedScopes descr, + enforced /= documented + ] + +-------------------------------------------------------------------------------- +-- the actual tests + +testPatternVocabulary :: Assertion +testPatternVocabulary = + for_ nginzLocations $ \loc -> + for_ pcreOnlyConstructs $ \bad -> + when (bad `T.isInfixOf` locPattern loc) $ + assertFailure . T.unpack $ + "charts/nginz/values.yaml: the path pattern " + <> locPattern loc + <> " uses '" + <> bad + <> "', which nginx reads as PCRE but this test matches with regex-tdfa, " + <> "i.e. POSIX ERE. The two may disagree, which would be bad." + +-- | 'enforcedScopes' ignores scopes brig cannot issue, so a typo in an +-- @oauth_scope:@ would otherwise make every endpoint under it drop silently out +-- of the comparison. Require that each name is usable at some tier. +testScopeNamesAreReal :: Assertion +testScopeNamesAreReal = + for_ (nub (mapMaybe locScope nginzLocations)) $ \base -> + unless (any (\tier -> (tier <> ":" <> base) `Set.member` grantableScopes) ["read", "write", "admin"]) $ + assertFailure . T.unpack $ + "charts/nginz/values.yaml: 'oauth_scope: " + <> base + <> "' matches no scope in Wire.API.OAuth.OAuthScope at any tier, so no " + <> "OAuth token can ever satisfy it and every endpoint under that " + <> "location is closed to OAuth.\nEither fix the name, or add the scope." + +testScopesAgree :: Assertion +testScopesAgree = do + unless (Set.null actual) . assertFailure . T.unpack . T.unlines $ + [ "OAuth scope declarations are out of sync.", + "", + "Columns: version, method, path, accepted by nginz, documented in swagger.", + "'-' means no scope. A finding means those last two disagree:", + "", + " enforced but not documented charts/nginz/values.yaml requires a scope the", + " swagger docs do not mention -- most likely a", + " missing DescriptionOAuthScope in the routing", + " table, e.g. on a newly added version of an", + " endpoint that already had one.", + " documented but not enforced the swagger docs promise a scope nginz does not", + " require -- a stale annotation, or a missing", + " oauth_scope: in charts/nginz/values.yaml.", + "" + ] + <> section "deviations:" actual + where + actual = Set.fromList (renderFinding <$> findings) + section title xs + | Set.null xs = [] + | otherwise = [" " <> title] <> ((" " <>) <$> Set.toAscList xs) <> [""] diff --git a/libs/wire-api/test/unit/Test/Wire/API/Run.hs b/libs/wire-api/test/unit/Test/Wire/API/Run.hs index cf0f89456c3..3e30589b08c 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Run.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Run.hs @@ -24,6 +24,7 @@ import Test.Wire.API.Call.Config qualified as Call.Config import Test.Wire.API.Conversation qualified as Conversation import Test.Wire.API.MLS qualified as MLS import Test.Wire.API.MLS.Group qualified as Group +import Test.Wire.API.Meeting qualified as Meeting import Test.Wire.API.OAuth qualified as OAuth import Test.Wire.API.RawJson qualified as RawJson import Test.Wire.API.Roundtrip.Aeson qualified as Roundtrip.Aeson @@ -33,6 +34,7 @@ import Test.Wire.API.Roundtrip.HttpApiData qualified as Roundtrip.HttpApiData import Test.Wire.API.Roundtrip.MLS qualified as Roundtrip.MLS import Test.Wire.API.Roundtrip.PostgresMarshall as PostgresMarshall import Test.Wire.API.Routes qualified as Routes +import Test.Wire.API.Routes.OAuthScopes qualified as Routes.OAuthScopes import Test.Wire.API.Routes.Version qualified as Routes.Version import Test.Wire.API.Routes.Version.Wai qualified as Routes.Version.Wai import Test.Wire.API.Swagger qualified as Swagger @@ -62,7 +64,9 @@ main = Swagger.tests, Roundtrip.CSV.tests, Routes.tests, + Routes.OAuthScopes.tests, Conversation.tests, + Meeting.tests, MLS.tests, Group.tests, Routes.Version.tests, diff --git a/libs/wire-api/test/unit/Test/Wire/API/User.hs b/libs/wire-api/test/unit/Test/Wire/API/User.hs index ce4db2890ea..4285ecf5285 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/User.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/User.hs @@ -135,7 +135,8 @@ testUserProfile = do profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } let profileJSONAsText = show $ Aeson.encode userProfile let msg = "toJSON encoding must not convert Nothing to null, but instead omit those json fields for backwards compatibility. UserProfileJSON:" <> profileJSONAsText diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 1ef9703bef6..e7f4e0886e0 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -105,6 +105,7 @@ library Wire.API.Event.Federation Wire.API.Event.Gundeck Wire.API.Event.LeaveReason + Wire.API.Event.Meeting Wire.API.Event.Team Wire.API.Event.WebSocketProtocol Wire.API.FederationStatus @@ -112,6 +113,7 @@ library Wire.API.History Wire.API.Internal.BulkPush Wire.API.Internal.Notification + Wire.API.Jobs Wire.API.Locale Wire.API.Meeting Wire.API.Message @@ -217,6 +219,7 @@ library Wire.API.Routes.Public.Gundeck Wire.API.Routes.Public.Proxy Wire.API.Routes.Public.Spar + Wire.API.Routes.Public.Swagger Wire.API.Routes.Public.Util Wire.API.Routes.QualifiedCapture Wire.API.Routes.SpecialiseToVersion @@ -283,6 +286,7 @@ library build-depends: , aeson >=2.0.1.0 , amqp + , arbiter-core , attoparsec >=0.10 , barbies , base >=4 && <5 @@ -374,6 +378,7 @@ library , tinylog , transformers , types-common >=0.16 + , tz , unordered-containers >=0.2 , uri-bytestring >=0.2 , utf8-string @@ -615,6 +620,7 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Generated.Wrapped_20_22some_5fint_22_20Int_user Test.Wire.API.Golden.Manual Test.Wire.API.Golden.Manual.Activate_user + Test.Wire.API.Golden.Manual.AdminlessJobs Test.Wire.API.Golden.Manual.App Test.Wire.API.Golden.Manual.CannonId Test.Wire.API.Golden.Manual.ClientCapability @@ -643,7 +649,10 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Manual.ListUsersById Test.Wire.API.Golden.Manual.Login_user Test.Wire.API.Golden.Manual.LoginId_user + Test.Wire.API.Golden.Manual.Meeting + Test.Wire.API.Golden.Manual.MeetingEvent Test.Wire.API.Golden.Manual.MLSKeys + Test.Wire.API.Golden.Manual.MlsMigrationConfig Test.Wire.API.Golden.Manual.Pagination Test.Wire.API.Golden.Manual.Presence Test.Wire.API.Golden.Manual.Push @@ -707,6 +716,7 @@ test-suite wire-api-tests Paths_wire_api Test.Wire.API.Call.Config Test.Wire.API.Conversation + Test.Wire.API.Meeting Test.Wire.API.MLS Test.Wire.API.MLS.Group Test.Wire.API.OAuth @@ -718,6 +728,7 @@ test-suite wire-api-tests Test.Wire.API.Roundtrip.MLS Test.Wire.API.Roundtrip.PostgresMarshall Test.Wire.API.Routes + Test.Wire.API.Routes.OAuthScopes Test.Wire.API.Routes.Version Test.Wire.API.Routes.Version.Wai Test.Wire.API.Run @@ -742,6 +753,8 @@ test-suite wire-api-tests , cassava , containers >=0.5 , crypton + , data-default + , file-embed , filepath , hex , hspec @@ -755,6 +768,7 @@ test-suite wire-api-tests , QuickCheck , ram , random + , regex-tdfa , schema-profunctor , servant , servant-server @@ -763,6 +777,7 @@ test-suite wire-api-tests , tasty-hspec , tasty-hunit , tasty-quickcheck + , template-haskell , text , time , types-common >=0.16 @@ -771,6 +786,7 @@ test-suite wire-api-tests , vector , wai , wire-api + , yaml ghc-options: -threaded -with-rtsopts=-N -Wunused-packages -Wno-x-partial diff --git a/libs/wire-subsystems/default.nix b/libs/wire-subsystems/default.nix index 50b26ec421f..4d3b1d9cbd0 100644 --- a/libs/wire-subsystems/default.nix +++ b/libs/wire-subsystems/default.nix @@ -11,6 +11,9 @@ , amazonka-ses , amazonka-sqs , amqp +, arbiter-core +, arbiter-hasql +, arbiter-migrations , async , attoparsec , base @@ -30,6 +33,7 @@ , contravariant , cookie , cql +, cron , crypton , crypton-asn1-encoding , crypton-asn1-types @@ -53,7 +57,7 @@ , HaskellNet-SSL , hasql , hasql-migration -, hasql-pool +, hasql-resource-pool , hasql-th , hasql-transaction , hex @@ -70,6 +74,7 @@ , imports , iproute , iso639 +, jwt-tools , kan-extensions , lens , lens-aeson @@ -83,10 +88,12 @@ , network-conduit-tls , network-uri , polysemy +, polysemy-check , polysemy-conc , polysemy-plugin , polysemy-time , polysemy-wire-zoo +, postgresql-connection-string , postgresql-error-codes , profunctors , prometheus-client @@ -110,6 +117,7 @@ , sop-core , ssl-util , statistics +, stm , stomp-queue , string-conversions , tagged @@ -152,6 +160,9 @@ mkDerivation { amazonka-ses amazonka-sqs amqp + arbiter-core + arbiter-hasql + arbiter-migrations async attoparsec base @@ -171,6 +182,7 @@ mkDerivation { contravariant cookie cql + cron crypton crypton-asn1-encoding crypton-asn1-types @@ -192,7 +204,7 @@ mkDerivation { HaskellNet-SSL hasql hasql-migration - hasql-pool + hasql-resource-pool hasql-th hasql-transaction hex @@ -208,6 +220,7 @@ mkDerivation { imports iproute iso639 + jwt-tools kan-extensions lens lens-aeson @@ -220,10 +233,12 @@ mkDerivation { network-conduit-tls network-uri polysemy + polysemy-check polysemy-conc polysemy-plugin polysemy-time polysemy-wire-zoo + postgresql-connection-string postgresql-error-codes profunctors prometheus-client @@ -244,6 +259,7 @@ mkDerivation { sop-core ssl-util statistics + stm stomp-queue tagged template @@ -281,6 +297,9 @@ mkDerivation { amazonka-ses amazonka-sqs amqp + arbiter-core + arbiter-hasql + arbiter-migrations async attoparsec base @@ -299,6 +318,7 @@ mkDerivation { contravariant cookie cql + cron crypton crypton-asn1-encoding crypton-asn1-types @@ -322,7 +342,7 @@ mkDerivation { HaskellNet-SSL hasql hasql-migration - hasql-pool + hasql-resource-pool hasql-th hasql-transaction hex @@ -337,6 +357,7 @@ mkDerivation { imports iproute iso639 + jwt-tools kan-extensions lens lens-aeson @@ -348,10 +369,12 @@ mkDerivation { network-conduit-tls network-uri polysemy + polysemy-check polysemy-conc polysemy-plugin polysemy-time polysemy-wire-zoo + postgresql-connection-string profunctors prometheus-client proto-lens @@ -374,6 +397,7 @@ mkDerivation { sop-core ssl-util statistics + stm stomp-queue string-conversions tagged diff --git a/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql b/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql new file mode 100644 index 00000000000..ff2f6bdd75f --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260708090000-meetings-recurrence-eff-end-index.sql @@ -0,0 +1,27 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Partial expression index for the recurring-meeting branches of +-- listMeetingsByUserImpl, listMeetingsByConversationImpl and +-- getOldMeetingsImpl, which filter/order on +-- GREATEST(end_time, recurrence_until). Non-recurring meetings keep using +-- idx_meetings_end_time. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_meetings_recurrence_eff_end + ON meetings (GREATEST(end_time, recurrence_until)) + WHERE recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL + AND recurrence_until IS NOT NULL; diff --git a/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql b/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql new file mode 100644 index 00000000000..555f8ed2674 --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260708100000-meetings-end-time-nonrecurring-index.sql @@ -0,0 +1,24 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Partial index for the non-recurring branch of getOldMeetingsImpl, so the +-- cleanup worker can find expired non-recurring meetings ordered by end_time +-- without scanning not-yet-expired recurring meetings (which carry an old +-- end_time but a recurrence window still open in the future). +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_meetings_end_time_nonrecurring + ON meetings (end_time) + WHERE recurrence_frequency IS NULL; diff --git a/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql b/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql new file mode 100644 index 00000000000..88447a697dd --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260713120000-meetings-recurrence-consistency-constraint.sql @@ -0,0 +1,32 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- Migration: add CHECK constraint enforcing meetings recurrence consistency. +-- Description: recurrence_frequency is the master switch. NULL => non-recurring +-- (interval and until must be NULL). NOT NULL => recurring, which requires +-- interval NOT NULL; recurrence_until stays optional (open-ended recurring +-- meetings never expire and are intentionally excluded from cleanup). + +ALTER TABLE meetings + ADD CONSTRAINT meetings_recurrence_consistency CHECK ( + (recurrence_frequency IS NULL + AND recurrence_interval IS NULL + AND recurrence_until IS NULL) + OR + (recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL) + ); diff --git a/libs/wire-subsystems/postgres-migrations/20260729120000-meetings-tzid.sql b/libs/wire-subsystems/postgres-migrations/20260729120000-meetings-tzid.sql new file mode 100644 index 00000000000..581b6a5266c --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260729120000-meetings-tzid.sql @@ -0,0 +1 @@ +ALTER TABLE meetings ADD COLUMN tzid text; diff --git a/libs/wire-subsystems/postgres-migrations/20260729120500-meetings-tzid-notnull.sql b/libs/wire-subsystems/postgres-migrations/20260729120500-meetings-tzid-notnull.sql new file mode 100644 index 00000000000..e5f92aa399f --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260729120500-meetings-tzid-notnull.sql @@ -0,0 +1,6 @@ +-- Backfill the (nullable) meetings.tzid column added by the preceding migration +-- with the default legacy time zone, then make it NOT NULL. There is no column +-- DEFAULT: the application always supplies the value on create (V17) or via the +-- galley meetings.legacyTimeZone config on the legacy (< V17) create path. +UPDATE meetings SET tzid = 'Europe/Berlin' WHERE tzid IS NULL; +ALTER TABLE meetings ALTER COLUMN tzid SET NOT NULL; diff --git a/libs/wire-subsystems/postgres-migrations/20260819120000-meetings-conversation-access.sql b/libs/wire-subsystems/postgres-migrations/20260819120000-meetings-conversation-access.sql new file mode 100644 index 00000000000..781192475f0 --- /dev/null +++ b/libs/wire-subsystems/postgres-migrations/20260819120000-meetings-conversation-access.sql @@ -0,0 +1,6 @@ +-- Meeting conversations are created with access {invite, code} instead of +-- {private, invite}, so that they can be joined by conversation code +-- (WPB-28155). Backfill existing meeting conversations with the new set. +-- Access ints (accessToInt32): private=1, invite=2, link=3, code=4. +-- GroupConvType ints (fromEnum): group=0, channel=1, meeting=2. +UPDATE conversation SET access = '{2,4}' WHERE group_conv_type = 2; diff --git a/libs/wire-subsystems/src/Wire/AWS.hs b/libs/wire-subsystems/src/Wire/AWS.hs index 78b7e74eff6..349145ab7e5 100644 --- a/libs/wire-subsystems/src/Wire/AWS.hs +++ b/libs/wire-subsystems/src/Wire/AWS.hs @@ -17,6 +17,23 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.AWS where import Amazonka qualified as AWS diff --git a/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs b/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs index 06b331cebb1..0b6f656e363 100644 --- a/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs +++ b/libs/wire-subsystems/src/Wire/ActivationCodeStore.hs @@ -54,5 +54,11 @@ data ActivationCodeStore :: Effect where -- | The user with whom to associate the activation code. Maybe UserId -> ActivationCodeStore m Activation + -- | Delete a pending activation code for a given 'EmailKey', if any. + -- This is used to invalidate a pending email-address update (e.g. when a + -- user is put under SCIM control). + DeleteActivationCode :: + EmailKey -> + ActivationCodeStore m () makeSem ''ActivationCodeStore diff --git a/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs index 56ff6f247a5..24d2f3c2737 100644 --- a/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ActivationCodeStore/Cassandra.hs @@ -42,6 +42,7 @@ interpretActivationCodeStoreToCassandra casClient = liftIO (mkActivationKey ek) >>= retry x1 . query1 cql . params LocalQuorum . Identity NewActivationCode ek timeout uid -> newActivationCodeImpl ek timeout uid + DeleteActivationCode ek -> deleteActivationCodeImpl ek where cql :: PrepQuery R (Identity ActivationKey) (Maybe UserId, ActivationCode) cql = @@ -72,6 +73,15 @@ newActivationCodeImpl uk timeout u = do ActivationCode . Ascii.unsafeFromText . pack . printf "%06d" <$> randIntegerZeroToNMinusOne 1000000 +-- | Delete a pending activation code for a given 'EmailKey', if any. +deleteActivationCodeImpl :: + (MonadClient m) => + EmailKey -> + m () +deleteActivationCodeImpl uk = do + key <- liftIO $ mkActivationKey uk + retry x5 . write keyDelete $ params LocalQuorum (Identity key) + -------------------------------------------------------------------------------- -- Utilities @@ -91,6 +101,9 @@ keyInsert = \(key, key_type, key_text, code, user, retries) VALUES \ \(? , ? , ? , ? , ? , ? ) USING TTL ?" +keyDelete :: PrepQuery W (Identity ActivationKey) () +keyDelete = "DELETE FROM activation_keys WHERE key = ?" + -- | Max. number of activation attempts per 'ActivationKey'. maxAttempts :: Int32 maxAttempts = 3 diff --git a/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs b/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs index 2f46d3ee28d..a7a37fdee80 100644 --- a/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/AppStore/Postgres.hs @@ -24,22 +24,16 @@ where import Data.Id import Data.Range -import Hasql.Pool import Hasql.TH import Imports import Polysemy -import Polysemy.Error (Error) -import Polysemy.Input import Wire.API.PostgresMarshall import Wire.API.User qualified as User import Wire.AppStore import Wire.Postgres interpretAppStoreToPostgres :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => InterpreterFor AppStore r interpretAppStoreToPostgres = interpret $ \case @@ -50,10 +44,7 @@ interpretAppStoreToPostgres = DeleteApp userId teamId -> deleteAppImpl userId teamId createAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => StoredApp -> Sem r () createAppImpl app = @@ -64,10 +55,7 @@ createAppImpl app = values ($1 :: uuid, $2 :: uuid, $3 :: json, $4 :: text, $5 :: text, $6 :: uuid) |] getAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Sem r (Maybe StoredApp) @@ -85,10 +73,7 @@ eraseMetadata :: StoredApp -> StoredApp eraseMetadata sap = sap {meta = mempty} getAppsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => TeamId -> Sem r [StoredApp] getAppsImpl tid = @@ -99,10 +84,7 @@ getAppsImpl tid = from apps where team_id = ($1 :: uuid) |] updateAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => TeamId -> UserId -> StoredAppUpdate -> @@ -137,10 +119,7 @@ updateAppImpl (toUUID -> teamId) (toUUID -> appId) upd = do pure $ maybe (Left NotFound) (\_ -> Right ()) found deleteAppImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Sem r () diff --git a/libs/wire-subsystems/src/Wire/AppSubsystem.hs b/libs/wire-subsystems/src/Wire/AppSubsystem.hs index ed16ba5bfa7..0a41bb91fbb 100644 --- a/libs/wire-subsystems/src/Wire/AppSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/AppSubsystem.hs @@ -69,10 +69,9 @@ data AppSubsystem m a where UserId -> Maybe PlainTextPassword6 -> AppSubsystem m (Either RetryAfter SomeUserToken) - DeleteApp :: - TeamId -> - UserId -> - AppSubsystem m () + -- | Delete app. This is called when deleting team members. It + -- does not check authentication. + InternalDeleteApp :: TeamId -> UserId -> AppSubsystem m () makeSem ''AppSubsystem diff --git a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs index 513deff6bfa..e1dd13ff30f 100644 --- a/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/AppSubsystem/Interpreter.hs @@ -83,7 +83,7 @@ runAppSubsystem runUser runAuth = GetApps lusr tid -> getAppsImpl lusr tid UpdateApp lusr tid uid put -> updateAppImpl lusr tid uid put RefreshAppCookie lusr tid appId password -> runError $ refreshAppCookieImpl lusr tid appId password - DeleteApp tid appId -> deleteAppImpl tid appId + InternalDeleteApp tid appId -> internalDeleteAppImpl tid appId createAppImpl :: ( Member UserStore r, @@ -138,7 +138,7 @@ createAppImpl lusr tid newApp = do pure CreatedApp { user = - let usr :: User = newStoredUserToUser (tUntagged (qualifyAs lusr u)) + let usr :: User = newStoredUserToUser (tUntagged (qualifyAs lusr u)) Nothing mbApp :: Maybe AppInfo = Just $ storedAppToAppInfo app lh = UserLegalHoldDisabled -- FUTUREWORK: this needs to be changed as soon as apps can be put under LH. in mkUserProfile EmailVisibleIfOnTeam usr mbApp lh, @@ -276,7 +276,6 @@ appNewStoredUser creator new = do country = loc.lCountry, providerId = Nothing, serviceId = Nothing, - handle = Nothing, expires = Nothing, teamId = creator.teamId, managedBy = defaultManagedBy, @@ -287,10 +286,10 @@ appNewStoredUser creator new = do defAppSupportedProtocols :: Set BaseProtocolTag defAppSupportedProtocols = Set.singleton BaseProtocolMLSTag -deleteAppImpl :: +internalDeleteAppImpl :: (Member AppStore r) => TeamId -> UserId -> Sem r () -deleteAppImpl teamId appId = +internalDeleteAppImpl teamId appId = Store.deleteApp appId teamId diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs index 0cca777728b..3c3012e876c 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher.hs @@ -21,9 +21,9 @@ module Wire.BackgroundJobsPublisher where import Data.Id import Polysemy -import Wire.API.BackgroundJobs (JobPayload) +import Wire.API.BackgroundJobs (BackgroundJobPayload) -data BackgroundJobsPublisher m a where - PublishJob :: JobId -> JobPayload -> BackgroundJobsPublisher m () +data BackgroundJobPublisher m a where + PublishJob :: JobId -> BackgroundJobPayload -> BackgroundJobPublisher m () -makeSem ''BackgroundJobsPublisher +makeSem ''BackgroundJobPublisher diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs index fecd5ca6bd2..1b3e9cb273b 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsPublisher/RabbitMQ.hs @@ -24,14 +24,14 @@ import Imports import Network.AMQP qualified as Q import Polysemy import Wire.API.BackgroundJobs -import Wire.BackgroundJobsPublisher (BackgroundJobsPublisher (..)) +import Wire.BackgroundJobsPublisher (BackgroundJobPublisher (..)) -interpretBackgroundJobsPublisherRabbitMQ :: +interpretBackgroundJobPublisherRabbitMQ :: (Member (Embed IO) r) => RequestId -> MVar Q.Channel -> - InterpreterFor BackgroundJobsPublisher r -interpretBackgroundJobsPublisherRabbitMQ requestId channelMVar = + InterpreterFor BackgroundJobPublisher r +interpretBackgroundJobPublisherRabbitMQ requestId channelMVar = interpret $ \case PublishJob jobId jobPayload -> do channel <- readMVar channelMVar @@ -42,11 +42,11 @@ publishJob :: RequestId -> Q.Channel -> JobId -> - JobPayload -> + BackgroundJobPayload -> Sem r () publishJob requestId channel jobId jobPayload = do let job = - Job + BackgroundJob { payload = jobPayload, jobId = jobId, requestId = requestId diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs index a3247884945..d54e2d64a07 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner.hs @@ -20,9 +20,9 @@ module Wire.BackgroundJobsRunner where import Polysemy -import Wire.API.BackgroundJobs (Job) +import Wire.API.BackgroundJobs (BackgroundJob) -data BackgroundJobsRunner m a where - RunJob :: Job -> BackgroundJobsRunner m () +data BackgroundJobRunner m a where + RunJob :: BackgroundJob -> BackgroundJobRunner m () -makeSem ''BackgroundJobsRunner +makeSem ''BackgroundJobRunner diff --git a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs index a319f729a8c..8fc5abdc680 100644 --- a/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/BackgroundJobsRunner/Interpreter.hs @@ -42,7 +42,7 @@ import Wire.API.Event.Conversation import Wire.API.Team.HardTruncationLimit (hardTruncationLimit) import Wire.API.UserGroup import Wire.BackgroundJobsPublisher -import Wire.BackgroundJobsRunner (BackgroundJobsRunner (..)) +import Wire.BackgroundJobsRunner (BackgroundJobRunner (..)) import Wire.ConversationStore (ConversationStore, upsertMembers) import Wire.ConversationSubsystem import Wire.Sem.Random @@ -50,33 +50,33 @@ import Wire.StoredConversation import Wire.UserGroupStore (UserGroupStore, getUserGroup, getUserGroupChannels) import Wire.UserList (toUserList) -interpretBackgroundJobsRunner :: +interpretBackgroundJobRunner :: ( Member UserGroupStore r, - Member BackgroundJobsPublisher r, + Member BackgroundJobPublisher r, Member (Input (Local ())) r, Member ConversationStore r, Member ConversationSubsystem r, Member Random r, Member TinyLog r ) => - InterpreterFor BackgroundJobsRunner r -interpretBackgroundJobsRunner = interpret $ \case - RunJob job -> runJob job + InterpreterFor BackgroundJobRunner r +interpretBackgroundJobRunner = interpret $ \case + RunJob job -> runBackgroundJob job -runJob :: +runBackgroundJob :: ( Member UserGroupStore r, - Member BackgroundJobsPublisher r, + Member BackgroundJobPublisher r, Member (Input (Local ())) r, Member ConversationStore r, Member ConversationSubsystem r, Member Random r, Member TinyLog r ) => - Job -> + BackgroundJob -> Sem r () -runJob job = case job.payload of - JobSyncUserGroupAndChannel payload -> runSyncUserGroupAndChannel payload - JobSyncUserGroup payload -> runSyncUserGroup payload +runBackgroundJob job = case job.payload of + BackgroundJobSyncUserGroupAndChannel payload -> runSyncUserGroupAndChannel payload + BackgroundJobSyncUserGroup payload -> runSyncUserGroup payload runSyncUserGroupAndChannel :: ( Member UserGroupStore r, @@ -145,7 +145,7 @@ runSyncUserGroupAndChannel (SyncUserGroupAndChannel {..}) = do runSyncUserGroup :: ( Member UserGroupStore r, - Member BackgroundJobsPublisher r, + Member BackgroundJobPublisher r, Member Random r, Member TinyLog r ) => @@ -162,4 +162,4 @@ runSyncUserGroup SyncUserGroup {..} = do for_ channels $ \convId -> do let syncUserGroupAndChannel = SyncUserGroupAndChannel {..} jobId <- newId - publishJob jobId (JobSyncUserGroupAndChannel syncUserGroupAndChannel) + publishJob jobId (BackgroundJobSyncUserGroupAndChannel syncUserGroupAndChannel) diff --git a/services/galley/src/Galley/Effects/Queue.hs b/libs/wire-subsystems/src/Wire/BoundedQueue.hs similarity index 84% rename from services/galley/src/Galley/Effects/Queue.hs rename to libs/wire-subsystems/src/Wire/BoundedQueue.hs index 66ba5d7bbb4..a51f6eb64a7 100644 --- a/services/galley/src/Galley/Effects/Queue.hs +++ b/libs/wire-subsystems/src/Wire/BoundedQueue.hs @@ -17,8 +17,8 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Galley.Effects.Queue - ( Queue (..), +module Wire.BoundedQueue + ( BoundedQueue (..), tryPush, pop, ) @@ -27,8 +27,8 @@ where import Imports import Polysemy -data Queue a m x where - TryPush :: a -> Queue a m Bool - Pop :: Queue a m a +data BoundedQueue a m x where + TryPush :: a -> BoundedQueue a m Bool + Pop :: BoundedQueue a m a -makeSem ''Queue +makeSem ''BoundedQueue diff --git a/services/galley/src/Galley/Queue.hs b/libs/wire-subsystems/src/Wire/BoundedQueue/STM.hs similarity index 89% rename from services/galley/src/Galley/Queue.hs rename to libs/wire-subsystems/src/Wire/BoundedQueue/STM.hs index de320bde244..3c697a3c0a5 100644 --- a/services/galley/src/Galley/Queue.hs +++ b/libs/wire-subsystems/src/Wire/BoundedQueue/STM.hs @@ -17,21 +17,21 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Galley.Queue +module Wire.BoundedQueue.STM ( Queue, new, tryPush, pop, len, - interpretQueue, + interpretBoundedQueue, ) where import Control.Concurrent.STM qualified as Stm -import Galley.Effects.Queue qualified as E import Imports import Numeric.Natural (Natural) import Polysemy +import Wire.BoundedQueue qualified as E data Queue a = Queue { _len :: Stm.TVar Word, @@ -57,11 +57,11 @@ pop q = liftIO . atomically $ do len :: (MonadIO m) => Queue a -> m Word len q = liftIO $ Stm.readTVarIO (_len q) -interpretQueue :: +interpretBoundedQueue :: (Member (Embed IO) r) => Queue a -> - Sem (E.Queue a ': r) x -> + Sem (E.BoundedQueue a ': r) x -> Sem r x -interpretQueue q = interpret $ \case +interpretBoundedQueue q = interpret $ \case E.TryPush a -> embed @IO $ tryPush q a E.Pop -> embed @IO $ pop q diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs index ceec1624944..c23b679ed72 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess.hs @@ -84,7 +84,6 @@ data BrigAPIAccess m a where GetUsers :: [UserId] -> BrigAPIAccess m [User] DeleteUser :: UserId -> BrigAPIAccess m () GetContactList :: UserId -> BrigAPIAccess m [UserId] - GetRichInfoMultiUser :: [UserId] -> BrigAPIAccess m [(UserId, RichInfo)] GetSize :: TeamId -> BrigAPIAccess m TeamSize LookupClients :: [UserId] -> BrigAPIAccess m UserClients LookupClientsFull :: [UserId] -> BrigAPIAccess m UserClientsFull @@ -151,6 +150,7 @@ data BrigAPIAccess m a where SetName :: UserId -> Name -> BrigAPIAccess m () SetHandle :: UserId -> Handle -> BrigAPIAccess m () SetManagedBy :: UserId -> ManagedBy -> BrigAPIAccess m () + DeletePendingEmailUpdate :: UserId -> BrigAPIAccess m () SetSSOId :: UserId -> UserSSOId -> BrigAPIAccess m () SetRichInfo :: UserId -> RichInfo -> BrigAPIAccess m () SetLocale :: UserId -> Maybe Locale -> BrigAPIAccess m () diff --git a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs index f4bd3d9ea47..e42a4791392 100644 --- a/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs +++ b/libs/wire-subsystems/src/Wire/BrigAPIAccess/Rpc.hs @@ -100,8 +100,6 @@ interpretBrigAccess brigEndpoint = deleteUser uid GetContactList uid -> do getContactList uid - GetRichInfoMultiUser uids -> do - getRichInfoMultiUser uids GetUserExportData uid -> do getUserExportData uid GetSize tid -> do @@ -167,6 +165,8 @@ interpretBrigAccess brigEndpoint = setHandle uid handle SetManagedBy uid managedBy -> setManagedBy uid managedBy + DeletePendingEmailUpdate uid -> + deletePendingEmailUpdate uid SetSSOId uid ssoId -> setSSOId uid ssoId SetRichInfo uid richInfo -> @@ -370,20 +370,6 @@ getContactList uid = do . expect2xx cUsers <$> decodeBodyOrThrow "brig" r --- | Calls 'Brig.API.Internal.getRichInfoMultiH' -getRichInfoMultiUser :: - (Member Rpc r, Member (Input Endpoint) r, Member (Error ParseException) r) => - [UserId] -> - Sem r [(UserId, RichInfo)] -getRichInfoMultiUser = chunkify $ \uids -> do - resp <- - brigRequest $ - method GET - . paths ["/i/users/rich-info"] - . queryItem "ids" (toByteString' (List uids)) - . expect2xx - decodeBodyOrThrow "brig" resp - -- | Calls 'Brig.API.Internal.getUserExportDataH' getUserExportData :: (Member Rpc r, Member (Input Endpoint) r, Member (Error ParseException) r) => @@ -996,6 +982,18 @@ setManagedBy buid managedBy = do unless (statusCode resp == 200) $ rethrow "brig" resp +deletePendingEmailUpdate :: + (Member Rpc r, Member (Input Endpoint) r, Member (Error RpcException) r) => + UserId -> + Sem r () +deletePendingEmailUpdate buid = do + resp <- + brigRequest $ + method DELETE + . paths ["i", "users", toByteString' buid, "pending-email-update"] + unless (statusCode resp == 200) $ + rethrow "brig" resp + setSSOId :: (Member Rpc r, Member (Input Endpoint) r, Member (Error RpcException) r) => UserId -> diff --git a/services/brig/src/Brig/Budget.hs b/libs/wire-subsystems/src/Wire/BudgetStore.hs similarity index 60% rename from services/brig/src/Brig/Budget.hs rename to libs/wire-subsystems/src/Wire/BudgetStore.hs index 2cd24cdee95..0780ece29e0 100644 --- a/services/brig/src/Brig/Budget.hs +++ b/libs/wire-subsystems/src/Wire/BudgetStore.hs @@ -1,4 +1,4 @@ -{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE TemplateHaskell #-} -- This file is part of the Wire Server implementation. -- @@ -17,20 +17,22 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Brig.Budget - ( Budget (..), - BudgetKey (..), - Budgeted (..), - withBudget, - checkBudget, +module Wire.BudgetStore + ( BudgetStore (..), lookupBudget, insertBudget, + withBudget, + checkBudget, + Budget (..), + BudgetKey (..), + Budgeted (..), ) where -import Cassandra -import Data.Time.Clock +import Cassandra (Cql) +import Data.Time.Clock (NominalDiffTime) import Imports +import Polysemy data Budget = Budget { budgetTimeout :: !NominalDiffTime, @@ -46,19 +48,18 @@ data Budgeted a newtype BudgetKey = BudgetKey Text deriving (Eq, Show, Cql) +data BudgetStore m a where + LookupBudget :: BudgetKey -> BudgetStore m (Maybe Budget) + InsertBudget :: BudgetKey -> Budget -> BudgetStore m () + +makeSem ''BudgetStore + -- | @withBudget (BudgetKey "k") (Budget 30 5) action@ runs @action@ at most 5 times every 30 -- seconds. @"k"@ is used for keeping different calls to 'withBudget' apart; use something -- there that's unique to your context, like @"login#" <> uid@. -- -- See the docs in "Gundeck.ThreadBudget" for related work. --- --- FUTUREWORK: encourage caller to define their own type for budget keys (rather than using an --- untyped text), and represent the types in a way that guarantees that if i'm using a local --- type that i don't export, then nobody will be able to use my namespace. --- --- FUTUREWORK: exceptions are not handled very nicely, but it's not clear what it would mean --- to improve this. -withBudget :: (MonadClient m) => BudgetKey -> Budget -> m a -> m (Budgeted a) +withBudget :: (Member BudgetStore r) => BudgetKey -> Budget -> Sem r a -> Sem r (Budgeted a) withBudget k b ma = do Budget ttl val <- fromMaybe b <$> lookupBudget k let remaining = val - 1 @@ -70,7 +71,7 @@ withBudget k b ma = do pure (BudgetedValue a remaining) -- | Like 'withBudget', but does not decrease budget, only takes a look. -checkBudget :: (MonadClient m) => BudgetKey -> Budget -> m (Budgeted ()) +checkBudget :: (Member BudgetStore r) => BudgetKey -> Budget -> Sem r (Budgeted ()) checkBudget k b = do Budget ttl val <- fromMaybe b <$> lookupBudget k let remaining = val - 1 @@ -78,21 +79,3 @@ checkBudget k b = do if remaining < 0 then BudgetExhausted ttl else BudgetedValue () remaining - -lookupBudget :: (MonadClient m) => BudgetKey -> m (Maybe Budget) -lookupBudget k = fmap mk <$> query1 budgetSelect (params One (Identity k)) - where - mk (val, ttl) = Budget (fromIntegral ttl) val - -insertBudget :: (MonadClient m) => BudgetKey -> Budget -> m () -insertBudget k (Budget ttl val) = - retry x5 $ write budgetInsert (params One (k, val, round ttl)) - -------------------------------------------------------------------------------- --- Queries - -budgetInsert :: PrepQuery W (BudgetKey, Int32, Int32) () -budgetInsert = "INSERT INTO budget (key, budget) VALUES (?, ?) USING TTL ?" - -budgetSelect :: PrepQuery R (Identity BudgetKey) (Int32, Int32) -budgetSelect = "SELECT budget, ttl(budget) FROM budget where key = ?" diff --git a/libs/wire-subsystems/src/Wire/BudgetStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/BudgetStore/Cassandra.hs new file mode 100644 index 00000000000..ebc4df1a9d0 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/BudgetStore/Cassandra.hs @@ -0,0 +1,55 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.BudgetStore.Cassandra + ( budgetStoreToCassandra, + ) +where + +import Cassandra +import Imports +import Polysemy +import Wire.BudgetStore + +budgetStoreToCassandra :: + forall m r a. + (MonadClient m, Member (Embed m) r) => + Sem (BudgetStore ': r) a -> + Sem r a +budgetStoreToCassandra = + interpret $ + embed @m . \case + LookupBudget k -> lookupBudgetC k + InsertBudget k b -> insertBudgetC k b + +lookupBudgetC :: (MonadClient m) => BudgetKey -> m (Maybe Budget) +lookupBudgetC k = fmap mk <$> query1 budgetSelect (params One (Identity k)) + where + mk (val, ttl) = Budget (fromIntegral ttl) val + +insertBudgetC :: (MonadClient m) => BudgetKey -> Budget -> m () +insertBudgetC k (Budget ttl val) = + retry x5 $ write budgetInsert (params One (k, val, round ttl)) + +------------------------------------------------------------------------------- +-- Queries + +budgetInsert :: PrepQuery W (BudgetKey, Int32, Int32) () +budgetInsert = "INSERT INTO budget (key, budget) VALUES (?, ?) USING TTL ?" + +budgetSelect :: PrepQuery R (Identity BudgetKey) (Int32, Int32) +budgetSelect = "SELECT budget, ttl(budget) FROM budget where key = ?" diff --git a/libs/wire-subsystems/src/Wire/ClientStore.hs b/libs/wire-subsystems/src/Wire/ClientStore.hs index 61cc21e3344..3eda15bebda 100644 --- a/libs/wire-subsystems/src/Wire/ClientStore.hs +++ b/libs/wire-subsystems/src/Wire/ClientStore.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientStore where import Data.Id @@ -29,6 +46,7 @@ data ClientStore m a where LookupClientsBulk :: [UserId] -> ClientStore m (UserMap (Set Client)) LookupPubClientsBulk :: [UserId] -> ClientStore m (UserMap (Set PubClient)) LookupPrekeyIds :: UserId -> ClientId -> ClientStore m [PrekeyId] + LookupPrekeyPresenceBulk :: [(UserId, ClientId)] -> ClientStore m (Set (UserId, ClientId)) GetActivityTimestamps :: UserId -> ClientStore m [Maybe UTCTime] -- Proteus UpdatePrekeys :: UserId -> ClientId -> [UncheckedPrekeyBundle] -> ClientStore m () diff --git a/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs index 57143288b43..c09f908a697 100644 --- a/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ClientStore/Cassandra.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientStore.Cassandra ( ClientStoreCassandraEnv (..), interpretClientStoreCassandra, @@ -62,6 +79,7 @@ interpretClientStoreCassandra env = LookupClientsBulk uids -> runCasClient $ lookupClientsBulkImpl uids LookupPubClientsBulk uids -> runCasClient $ lookupPubClientsBulkImpl uids LookupPrekeyIds uid cid -> runCasClient $ lookupPrekeyIdsImpl uid cid + LookupPrekeyPresenceBulk pairs -> runCasClient $ lookupPrekeyPresenceBulkImpl pairs GetActivityTimestamps uid -> runCasClient $ getActivityTimestampsImpl uid -- Proteus UpdatePrekeys uid cid prekeys -> runCasClient $ updatePrekeysImpl uid cid prekeys @@ -142,6 +160,15 @@ lookupPrekeyIdsImpl u c = map runIdentity <$> retry x1 (query selectPrekeyIds (params LocalQuorum (u, c))) +lookupPrekeyPresenceBulkImpl :: (MonadClient m, MonadUnliftIO m) => [(UserId, ClientId)] -> m (Set.Set (UserId, ClientId)) +lookupPrekeyPresenceBulkImpl pairs = + Set.fromList . map fst . filter snd + <$> pooledMapConcurrentlyN 16 (\pair@(u, c) -> (pair,) <$> lookupPrekeyPresenceImpl u c) pairs + +lookupPrekeyPresenceImpl :: (MonadClient m) => UserId -> ClientId -> m Bool +lookupPrekeyPresenceImpl u c = + isJust <$> retry x1 (query1 selectPrekeyPresence (params LocalQuorum (u, c))) + getActivityTimestampsImpl :: (MonadClient m) => UserId -> m [Maybe UTCTime] getActivityTimestampsImpl uid = do runIdentity <$$> retry x1 (query q (params LocalQuorum (Identity uid))) @@ -319,6 +346,9 @@ userPrekeys = "SELECT key, data FROM prekeys where user = ? and client = ?" selectPrekeyIds :: PrepQuery R (UserId, ClientId) (Identity PrekeyId) selectPrekeyIds = "SELECT key FROM prekeys where user = ? and client = ?" +selectPrekeyPresence :: PrepQuery R (UserId, ClientId) (Identity PrekeyId) +selectPrekeyPresence = "SELECT key FROM prekeys where user = ? and client = ? LIMIT 1" + removePrekey :: PrepQuery W (UserId, ClientId, PrekeyId) () removePrekey = "DELETE FROM prekeys where user = ? and client = ? and key = ?" diff --git a/libs/wire-subsystems/src/Wire/ClientSubsystem.hs b/libs/wire-subsystems/src/Wire/ClientSubsystem.hs index 236eccdb016..de48a98a862 100644 --- a/libs/wire-subsystems/src/Wire/ClientSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ClientSubsystem.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem where import Data.Default diff --git a/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs b/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs index 8c9d24f31ed..a7c77ae7490 100644 --- a/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs +++ b/libs/wire-subsystems/src/Wire/ClientSubsystem/Error.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem.Error where import Data.Id diff --git a/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs index 79ea965c8f6..7aba247a75e 100644 --- a/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ClientSubsystem/Interpreter.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem.Interpreter ( runClientSubsystem, ClientError (..), diff --git a/libs/wire-subsystems/src/Wire/CodeStore.hs b/libs/wire-subsystems/src/Wire/CodeStore.hs index bae3e0da4cd..c5c73c6bf07 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore.hs @@ -20,6 +20,7 @@ module Wire.CodeStore where import Data.Code +import Data.Domain (Domain) import Data.Misc import Imports import Polysemy @@ -32,6 +33,6 @@ data CodeStore m a where DeleteCode :: Key -> CodeStore m () MakeKey :: CodeReferent -> CodeStore m Key GenerateCode :: CodeReferent -> Timeout -> CodeStore m Code - GetConversationCodeURI :: Maybe Text -> CodeStore m (Maybe HttpsUrl) + GetConversationCodeURI :: Maybe Domain -> CodeStore m (Maybe HttpsUrl) makeSem ''CodeStore diff --git a/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs index 3c42149f74e..2a06764be76 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/Cassandra.hs @@ -22,6 +22,7 @@ where import Cassandra import Data.Code +import Data.Domain (Domain) import Data.Id import Data.Map qualified as Map import Data.Misc (HttpsUrl) @@ -39,7 +40,7 @@ import Wire.Util (embedClientInput) interpretCodeStoreToCassandra :: ( Member (Embed IO) r, Member (Input ClientState) r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, Member (ErrorS 'CodeStoreNotFound) r ) => Sem (CodeStore ': r) a -> diff --git a/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs b/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs index 9beafb949a2..f567d3c353b 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/DualWrite.hs @@ -21,6 +21,7 @@ module Wire.CodeStore.DualWrite where import Cassandra (ClientState) +import Data.Domain (Domain) import Data.Misc import Imports import Polysemy @@ -37,7 +38,7 @@ import Wire.Postgres (PGConstraints) -- | Cassandra is the source of truth during migration; writes are mirrored to Postgres. interpretCodeStoreToCassandraAndPostgres :: ( Member (Input ClientState) r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, Member (ErrorS 'CodeStoreNotFound) r, PGConstraints r ) => diff --git a/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs b/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs index 14decb92f63..e652c5619a3 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/Migration.hs @@ -22,12 +22,13 @@ import Data.ByteString.Conversion import Data.Code (Key, Value) import Data.Conduit import Data.Conduit.List qualified as C +import Data.Domain (Domain) import Data.IORef qualified as IORef import Data.Id (ConvId) import Data.Misc (HttpsUrl) import Data.Text qualified as T import Data.Time -import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as Hasql import Imports import Polysemy import Polysemy.Async @@ -55,7 +56,7 @@ type EffectStack = [ State Int, Input ClientState, Input Hasql.Pool, - Input (Either HttpsUrl (Map Text HttpsUrl)), + Input (Either HttpsUrl (Map Domain HttpsUrl)), Resource, Async, Race, @@ -100,7 +101,7 @@ interpreter cassClient pgPool logger name = migrateAllCodes :: ( Member (Input Hasql.Pool) r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, Member (Embed IO) r, Member (Input ClientState) r, Member TinyLog r, @@ -119,7 +120,7 @@ migrateAllCodes migOpts migCounter migDuration = do .| C.mapM_ (traverse_ (\row@(key, _, _, _, _) -> handleErrors (toByteString' key) (migrateCodeRow migOpts migCounter migDuration row))) migrateCodeRow :: - ( Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r, + ( Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r, PGConstraints r, Member TinyLog r, Member Resource r, diff --git a/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs b/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs index 9f60db88959..9719c2a306e 100644 --- a/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/CodeStore/Postgres.hs @@ -21,6 +21,7 @@ module Wire.CodeStore.Postgres where import Data.Code +import Data.Domain (Domain) import Data.Id import Data.Map qualified as Map import Data.Misc (HttpsUrl) @@ -38,7 +39,7 @@ import Wire.Postgres interpretCodeStoreToPostgres :: ( PGConstraints r, - Member (Input (Either HttpsUrl (Map Text HttpsUrl))) r + Member (Input (Either HttpsUrl (Map Domain HttpsUrl))) r ) => Sem (CodeStore ': r) a -> Sem r a diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs index cdd72d0097c..8c5ddc11bd7 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Migration.hs @@ -34,7 +34,8 @@ import Data.Time import Data.Time.Calendar.OrdinalDate (fromOrdinalDate) import Data.Vector (Vector) import Data.Vector qualified as Vector -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) +import Hasql.Pool.Extended qualified as Hasql import Hasql.Statement qualified as Hasql import Hasql.TH import Hasql.Transaction qualified as Transaction @@ -196,7 +197,7 @@ migrateAllUsers migOpts migCounter migDuration = do select :: PrepQuery R () (Identity UserId) select = "select distinct user from user_remote_conv" -handleErrors :: (Member (State Int) r, Member TinyLog r) => (Id a -> Sem (Error MigrationLockError : Error Hasql.UsageError : r) b) -> ByteString -> Id a -> Sem r (Maybe b) +handleErrors :: (Member (State Int) r, Member TinyLog r) => (Id a -> Sem (Error MigrationLockError : Error UsageError : r) b) -> ByteString -> Id a -> Sem r (Maybe b) handleErrors action lockType id_ = join <$> handleError (handleError action lockType) lockType id_ diff --git a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs index 067cb970071..9dee5441e8e 100644 --- a/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/ConversationStore/Postgres.hs @@ -34,7 +34,6 @@ import Data.Vector qualified as Vector import GHC.Records (HasField) import Hasql.Decoders qualified as HD import Hasql.Pipeline qualified as Pipeline -import Hasql.Pool qualified as Hasql import Hasql.Session qualified as HasqlSession import Hasql.Statement qualified as Hasql import Hasql.TH @@ -43,8 +42,6 @@ import Hasql.Transaction qualified as Transaction import Hasql.Transaction.Sessions (IsolationLevel (ReadCommitted), Mode (..)) import Imports import Polysemy -import Polysemy.Error -import Polysemy.Input import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.CellsState import Wire.API.Conversation.Pagination @@ -1317,10 +1314,7 @@ rawResultToSearchResult r = do } searchConversationsImpl :: - ( Member (Input Hasql.Pool) r, - Member (Error Hasql.UsageError) r, - Member (Embed IO) r - ) => + (PGConstraints r) => ConversationSearch -> Sem r [ConversationSearchResult] searchConversationsImpl req = diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs index 44f7541d276..56b01784b8f 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem.hs @@ -32,6 +32,7 @@ import Data.Code qualified as Code import Data.CommaSeparatedList (CommaSeparatedList) import Data.Domain import Data.Id +import Data.Json.Util (UTCTimeMillis) import Data.Misc (IpAddr) import Data.Qualified import Data.Range @@ -341,6 +342,19 @@ data ConversationSubsystem m a where InternalDeleteLocalConversation :: Local ConvId -> ConversationSubsystem m () + InternalDeleteLocalAdminlessGroup :: + Maybe (Local UserId) -> + Local ConvId -> + ConversationSubsystem m () + InternalNotifyAdminlessReminder :: + Maybe (Local UserId) -> + Local ConvId -> + UTCTimeMillis -> + ConversationSubsystem m () + SetupAdminlessGroupsCleanup :: + Maybe (Local UserId) -> + TeamId -> + ConversationSubsystem m () GetMLSPublicKeys :: Maybe MLSPublicKeyFormat -> ConversationSubsystem m (MLSKeysByPurpose (MLSKeys SomeKey)) @@ -449,7 +463,7 @@ data ConversationSubsystem m a where ConvId -> ConversationSubsystem m (LockableFeature GuestLinksConfig) GetCode :: - Maybe Text -> + Maybe ZHostValue -> Local UserId -> ConvId -> ConversationSubsystem m ConversationCodeInfo @@ -468,6 +482,7 @@ data ConversationSubsystem m a where InviteQualified -> ConversationSubsystem m (UpdateResult Event) ReplaceMembers :: + RemoveMemberResponseMode -> Local UserId -> ConnId -> Qualified ConvId -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs index a6e6921db12..f7c128e29d8 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Action.hs @@ -51,6 +51,7 @@ module Wire.ConversationSubsystem.Action addLocalUsersToRemoteConv, ConversationUpdate, ensureAllowed, + removeConversation, ) where @@ -115,6 +116,7 @@ import Wire.BrigAPIAccess qualified as E import Wire.CodeStore import Wire.CodeStore qualified as E import Wire.CodeStore.Code (CodeReferent (..)) +import Wire.ConversationStore (ConversationStore) import Wire.ConversationStore qualified as E import Wire.ConversationSubsystem.Action.Kick import Wire.ConversationSubsystem.Action.Leave @@ -375,28 +377,7 @@ instance IsConversationAction 'ConversationDeleteTag where ] performAction lconv _qusr _conId () = do - let lcnv = fmap (.id_) lconv - storedConv = tUnqualified lconv - let deleteGroup groupId = do - E.removeAllMLSClients groupId - E.removeAllHistoryClients groupId - E.deleteAllProposals groupId - - let cid = storedConv.id_ - for_ (storedConv & mlsMetadata <&> cnvmlsGroupId . fst) $ \gidParent -> do - sconvs <- E.listSubConversations cid - for_ (Map.assocs sconvs) $ \(subid, mlsData) -> do - let gidSub = cnvmlsGroupId mlsData - E.deleteSubConversation cid subid - deleteGroup gidSub - deleteGroup gidParent - - key <- E.makeKey (CodeReferentConv (tUnqualified lcnv)) - E.deleteCode key - case convTeam storedConv of - Nothing -> E.deleteConversation (tUnqualified lcnv) - Just tid -> E.deleteTeamConversation tid (tUnqualified lcnv) - + removeConversation lconv pure $ mkPerformActionResult () ensureAllowed _ _action _conv (ActorContext Nothing (Just _tm)) = @@ -409,6 +390,36 @@ instance IsConversationAction 'ConversationDeleteTag where allowChannelManagePermission = True +removeConversation :: + ( Member ConversationStore r, + Member ProposalStore r, + Member CodeStore r + ) => + Local StoredConversation -> + Sem r () +removeConversation lconv = do + let lcnv = fmap (.id_) lconv + storedConv = tUnqualified lconv + let deleteGroup groupId = do + E.removeAllMLSClients groupId + E.removeAllHistoryClients groupId + E.deleteAllProposals groupId + + let cid = storedConv.id_ + for_ (storedConv & mlsMetadata <&> cnvmlsGroupId . fst) $ \gidMainConv -> do + sconvs <- E.listSubConversations cid + for_ (Map.assocs sconvs) $ \(subid, mlsData) -> do + let gidSub = cnvmlsGroupId mlsData + E.deleteSubConversation cid subid + deleteGroup gidSub + deleteGroup gidMainConv + + key <- E.makeKey (CodeReferentConv (tUnqualified lcnv)) + E.deleteCode key + case convTeam storedConv of + Nothing -> E.deleteConversation (tUnqualified lcnv) + Just tid -> E.deleteTeamConversation tid (tUnqualified lcnv) + instance IsConversationAction 'ConversationRenameTag where type HasConversationActionEffects 'ConversationRenameTag r = diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs index 8beac86b3a9..df14feaa080 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Federation.hs @@ -94,6 +94,7 @@ import Wire.FederationAPIAccess (FederationAPIAccess) import Wire.FederationSubsystem (FederationSubsystem) import Wire.FireAndForget qualified as E import Wire.LegalHoldStore (LegalHoldStore) +import Wire.MeetingNotifier import Wire.NotificationSubsystem import Wire.ProposalStore (ProposalStore) import Wire.Sem.Now (Now) @@ -575,7 +576,8 @@ handleMLSMessageErrors = . mapToGalleyError @MLSBundleStaticErrors sendMLSCommitBundle :: - ( Member BackendNotificationQueueAccess r, + ( Member MeetingNotifier r, + Member BackendNotificationQueueAccess r, Member BrigAPIAccess r, Member E.ConversationStore r, Member ExternalAccess r, diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs index 972ec7573b8..651d6c775e8 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Interpreter.hs @@ -71,7 +71,9 @@ import Wire.FederationAPIAccess (FederationAPIAccess) import Wire.FederationSubsystem (FederationSubsystem) import Wire.FireAndForget (FireAndForget) import Wire.HashPassword (HashPassword) +import Wire.JobSubsystem (JobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) +import Wire.MeetingNotifier (MeetingNotifier) import Wire.NotificationSubsystem as NS import Wire.Options.Galley (GuestLinkTTLSeconds) import Wire.ProposalStore (ProposalStore) @@ -85,7 +87,8 @@ import Wire.UserClientIndexStore (UserClientIndexStore) import Wire.UserGroupStore (UserGroupStore) interpretConversationSubsystem :: - ( Member (Error ConversationSubsystemError) r, + ( Member MeetingNotifier r, + Member (Error ConversationSubsystemError) r, Member (Error JSONResponse) r, Member (Error DynError) r, Member UserGroupStore r, @@ -115,6 +118,7 @@ interpretConversationSubsystem :: Member TeamStore r, Member ConvStore.MLSCommitLockStore r, Member FederationSubsystem r, + Member JobSubsystem r, Member Resource r, Member (Input (Maybe (MLSKeysByPurpose MLSPrivateKeys))) r, Member UserClientIndexStore r, @@ -211,6 +215,12 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Update.deleteLocalConversation lusr con lcnv InternalDeleteLocalConversation lcnv -> mapErrors $ Action.updateLocalConversationDeleteUnchecked lcnv + InternalDeleteLocalAdminlessGroup lusr lcnv -> + mapErrors $ Update.adminlessAutopromoteOrDelete lusr lcnv + InternalNotifyAdminlessReminder lusr lcnv deletionScheduledFor -> + mapErrors $ Update.adminlessAutopromoteOrSendReminder lusr lcnv deletionScheduledFor + SetupAdminlessGroupsCleanup lusr tid -> + mapErrors $ Update.setupAdminlessGroupsCleanup lusr tid GetMLSPublicKeys fmt -> mapErrors $ MLS.getMLSPublicKeys fmt ResetMLSConversation lusr reset -> @@ -261,8 +271,8 @@ interpretConversationSubsystem = interpret $ \case mapErrors $ Update.addQualifiedMembersUnqualified lusr con cnv invite AddMembers lusr zcon qcnv invite -> mapErrors $ Update.addMembers lusr zcon qcnv invite - ReplaceMembers lusr zcon qcnv invite -> - mapErrors $ Update.replaceMembers lusr zcon qcnv invite + ReplaceMembers responseMode lusr zcon qcnv invite -> + mapErrors $ Update.replaceMembers responseMode lusr zcon qcnv invite JoinConversationById lusr con cnv -> mapErrors $ Update.joinConversationById lusr con cnv JoinConversationByReusableCode lusr con req -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs index 1f61b224b8e..b5cc036c2c6 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/MLS/Message.hs @@ -93,6 +93,7 @@ import Wire.ExternalAccess import Wire.FeaturesConfigSubsystem import Wire.FederationAPIAccess import Wire.FederationSubsystem +import Wire.MeetingNotifier import Wire.NotificationSubsystem import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) @@ -166,7 +167,8 @@ postMLSMessageFromLocalUser v lusr c conn smsg = do pure $ MLSMessageSendingStatus events t postMLSCommitBundle :: - ( Member (ErrorS MLSLegalholdIncompatible) r, + ( Member MeetingNotifier r, + Member (ErrorS MLSLegalholdIncompatible) r, Member (ErrorS MLSIdentityMismatch) r, Member (Error GroupInfoDiagnostics) r, Member (Error MLSOutOfSyncError) r, @@ -201,7 +203,8 @@ postMLSCommitBundle loc qusr c ctype qConvOrSub conn oosCheck bundle = qConvOrSub postMLSCommitBundleFromLocalUser :: - ( Member (ErrorS MLSLegalholdIncompatible) r, + ( Member MeetingNotifier r, + Member (ErrorS MLSLegalholdIncompatible) r, Member (ErrorS MLSIdentityMismatch) r, Member (Error GroupInfoDiagnostics) r, Member (Error MLSOutOfSyncError) r, @@ -237,7 +240,8 @@ postMLSCommitBundleFromLocalUser v lusr c conn bundle = do pure $ MLSMessageSendingStatus events t postMLSCommitBundleToLocalConv :: - ( Member (ErrorS MLSLegalholdIncompatible) r, + ( Member MeetingNotifier r, + Member (ErrorS MLSLegalholdIncompatible) r, Member (ErrorS MLSIdentityMismatch) r, Member (Error GroupInfoDiagnostics) r, Member (Error MLSOutOfSyncError) r, @@ -382,6 +386,8 @@ postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do pure lConvOrSub' pure (events, newClients, lConvOrSub') + notifyNewMeetingMembers qusr lConvOrSub lConvOrSub' + -- send welcome messages for_ bundle.welcome $ \welcome -> sendWelcomes lConvOrSubId qusr conn (cmIdentities newClients) welcome @@ -393,6 +399,32 @@ postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do pure events +notifyNewMeetingMembers :: + (Member MeetingNotifier r) => + Qualified UserId -> + Local ConvOrSubConv -> + Local ConvOrSubConv -> + Sem r () +notifyNewMeetingMembers qUser before after = + case (tUnqualified before, tUnqualified after) of + (Conv beforeConv, Conv afterConv) + | isMeetingConv afterConv -> do + let beforeUsers = Set.fromList (map (.id_) beforeConv.mcLocalMembers) + afterUsers = Set.fromList (map (.id_) afterConv.mcLocalMembers) + addedUsers = newLocalMeetingMembers beforeUsers afterUsers + unless (null addedUsers) $ + notifyMeetingMembersAdded + qUser + (Qualified afterConv.mcId (tDomain after)) + afterConv.mcMetadata.cnvmTeam + addedUsers + _ -> pure () + where + isMeetingConv conv = + conv.mcMetadata.cnvmGroupConvType == Just MeetingConversation + newLocalMeetingMembers prev curr = + Set.toList (Set.difference curr prev) + handleGroupInfoMismatch :: (Member (Error GroupInfoDiagnostics) r) => Local ConvOrSubConvId -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs index a02de14f9aa..e40ea62e46f 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Notify.hs @@ -15,9 +15,15 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.ConversationSubsystem.Notify (notifyConversationActionImpl) where +module Wire.ConversationSubsystem.Notify + ( notifyConversationActionImpl, + pushSystemEvent, + ) +where +import Data.Default import Data.Id +import Data.Json.Util (ToJSONObject (toJSONObject)) import Data.Qualified import Data.Singletons (Sing) import Imports @@ -64,7 +70,14 @@ notifyConversationActionImpl tag eventFrom notifyOrigDomain con lconv targetsLoc let lcnv = fmap (.id_) lconv conv = tUnqualified lconv tid = conv.metadata.cnvmTeam - e = conversationActionToEvent tag now eventFrom (tUntagged lcnv) extraData Nothing tid action + -- Meeting conversations emit `conversation.delete-meeting` instead of + -- `conversation.delete`, mirroring `conversation.create-meeting` (#5302). + eBase = conversationActionToEvent tag now eventFrom (tUntagged lcnv) extraData Nothing tid action + e + | eBase.evtData == EdConvDelete + && conv.metadata.cnvmGroupConvType == Just MeetingConversation = + eBase {evtData = EdConvDeleteMeeting} + | otherwise = eBase quid = eventFromUserId eventFrom mkUpdate uids = ConversationUpdate @@ -89,3 +102,21 @@ notifyConversationActionImpl tag eventFrom notifyOrigDomain con lconv targetsLoc pushConversationEvent con conv.metadata.cnvmCellsState e (qualifyAs lcnv targetsLocal) targetsBots pure $ LocalConversationUpdate {lcuEvent = e, lcuUpdate = update} + +pushSystemEvent :: + (Member NotificationSubsystem r) => + Maybe ConnId -> + SystemEvent -> + Set UserId -> + Sem r () +pushSystemEvent con event targets = do + let eventJson = toJSONObject event + pushNotifications + [ def + { conn = con, + origin = Nothing, + json = eventJson, + recipients = map userRecipient (toList targets), + isCellsEvent = True + } + ] diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs index a96558746bd..78474964d8f 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Update.hs @@ -42,6 +42,9 @@ module Wire.ConversationSubsystem.Update updateConversationProtocolWithLocalUser, updateLocalStateOfRemoteConv, updateCellsState, + adminlessAutopromoteOrDelete, + adminlessAutopromoteOrSendReminder, + setupAdminlessGroupsCleanup, -- * Managing Members addQualifiedMembersUnqualified, @@ -51,6 +54,8 @@ module Wire.ConversationSubsystem.Update updateOtherMember, eligibleAdminFallbackMembers, isLeavingLastConversationAdmin, + guardPreventAdminlessGroups, + guardPreventAdminlessGroupsFor, removeMemberQualified, deleteUserFromTeamConversationsImpl, removeMemberFromLocalConv, @@ -84,6 +89,7 @@ import Data.Misc import Data.Qualified import Data.Set qualified as Set import Data.Singletons +import Data.Time.Clock (NominalDiffTime, addUTCTime) import Data.Vector qualified as V import Galley.Types.Error import Imports hiding (forkIO) @@ -91,6 +97,7 @@ import Polysemy import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog +import System.Logger qualified as Log import Wire.API.Bot hiding (addBot) import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Action @@ -132,6 +139,7 @@ import Wire.ConversationSubsystem.Action import Wire.ConversationSubsystem.Action.Kick (kickMember) import Wire.ConversationSubsystem.AdminlessGroups (selectAutopromotionCandidate) import Wire.ConversationSubsystem.Message +import Wire.ConversationSubsystem.Notify qualified as Notify import Wire.ConversationSubsystem.Query qualified as Query import Wire.ConversationSubsystem.Util import Wire.ExternalAccess qualified as E @@ -140,6 +148,7 @@ import Wire.FederationAPIAccess qualified as E import Wire.FederationSubsystem import Wire.FireAndForget import Wire.HashPassword as HashPassword +import Wire.JobSubsystem (JobSubsystem, scheduleAdminlessDeletionJob, scheduleAdminlessReminderJob) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem import Wire.Options.Galley @@ -972,14 +981,16 @@ replaceMembers :: Member FederationSubsystem r, Member FeaturesConfigSubsystem r, Member TeamSubsystem r, + Member JobSubsystem r, Member (Input ConversationSubsystemConfig) r ) => + RemoveMemberResponseMode -> Local UserId -> ConnId -> Qualified ConvId -> InviteQualified -> Sem r () -replaceMembers lusr zcon qcnv (InviteQualified invitedUsers role) = do +replaceMembers responseMode lusr zcon qcnv (InviteQualified invitedUsers role) = do lcnv <- ensureLocal lusr qcnv conv <- getConversationWithError lcnv @@ -993,14 +1004,23 @@ replaceMembers lusr zcon qcnv (InviteQualified invitedUsers role) = do permissionCheck JoinRegularConversations . Just ugs <- getUserGroupsForConv conv.id_ - -- Get current members (excluding the requesting user) - let currentMembers = Set.fromList $ map (\m -> Qualified m.id_ (tDomain lcnv)) (toList conv.localMembers) + -- Removals apply only to local members. Additions must account for remote + -- members too, because re-inviting an existing remote member does not change + -- their role and must not be treated as adding an admin. + let currentLocalMembers = Set.fromList $ map (\m -> Qualified m.id_ (tDomain lcnv)) (toList conv.localMembers) + currentRemoteMembers = Set.fromList $ map (tUntagged . (.id_)) conv.remoteMembers invitedMembersSet = Set.fromList $ toList invitedUsers ugMembers = concatMap (fmap (flip Qualified (tDomain lusr)) . V.toList . runIdentity . (.members)) (V.toList ugs) -- the invited users plus all user group members should stay allUsersThatShouldStay = Set.fromList $ toList $ appendList invitedUsers ugMembers - toRemove = Set.difference currentMembers allUsersThatShouldStay - toAdd = Set.difference invitedMembersSet currentMembers + toRemove = Set.difference currentLocalMembers allUsersThatShouldStay + toAdd = Set.difference invitedMembersSet (currentLocalMembers <> currentRemoteMembers) + addedAdmins = if role == roleNameWireAdmin then toAdd else Set.empty + + -- Apply the same adminless protection as DELETE to the complete removal + -- set. The guard is a preflight so V16+ cannot partially apply a replacement + -- before returning AdminlessConversation. + guardPreventAdminlessGroupsFor responseMode lcnv lusr toRemove addedAdmins toAdd -- If both sets are empty, return Unchanged unless (Set.null toRemove && Set.null toAdd) $ do @@ -1153,6 +1173,7 @@ removeMemberQualified :: Member TinyLog r, Member FeaturesConfigSubsystem r, Member TeamSubsystem r, + Member JobSubsystem r, Member (Input ConversationSubsystemConfig) r ) => RemoveMemberResponseMode -> @@ -1170,6 +1191,50 @@ removeMemberQualified responseMode lusr con qcnv victim = qcnv victim +isAdminlessCheckCandidate :: StoredConversation -> Bool +isAdminlessCheckCandidate conv = + conv.metadata.cnvmType == RegularConv + && maybe True (== GroupConversation) conv.metadata.cnvmGroupConvType + +shouldSkipSystemAdminlessDeletion :: Maybe (Local UserId) -> StoredConversation -> Bool +shouldSkipSystemAdminlessDeletion mlusr conv = + isNothing mlusr + && not (null conv.remoteMembers) + +logSkippedSystemAdminlessDeletion :: (Member TinyLog r) => Text -> StoredConversation -> Sem r () +logSkippedSystemAdminlessDeletion action conv = + info $ + Log.msg (Log.val "Skipping senderless adminless deletion for conversation with remote members") + . Log.field "conversation_id" (idToText conv.id_) + . Log.field "action" action + +setupAdminlessGroupsCleanup :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member (Input (Local ())) r, + Member JobSubsystem r, + Member TinyLog r + ) => + Maybe (Local UserId) -> + TeamId -> + Sem r () +setupAdminlessGroupsCleanup mUsr tid = do + teamConvIds <- E.getTeamConversations tid + for_ teamConvIds $ \cnv -> do + lcnv <- qualifyLocal cnv + adminlessTryAutopromote mUsr lcnv $ \conv feature _ -> + if shouldSkipSystemAdminlessDeletion mUsr conv + then logSkippedSystemAdminlessDeletion "schedule_for_deletion" conv + else scheduleDeletion lcnv mUsr tid feature + guardPreventAdminlessGroups :: ( Member ConversationStore r, Member (Error AdminlessConversation) r, @@ -1185,19 +1250,63 @@ guardPreventAdminlessGroups :: Member E.ExternalAccess r, Member BackendNotificationQueueAccess r, Member TeamSubsystem r, - Member FeaturesConfigSubsystem r + Member FeaturesConfigSubsystem r, + Member JobSubsystem r ) => RemoveMemberResponseMode -> Local ConvId -> Local UserId -> Qualified UserId -> Sem r () -guardPreventAdminlessGroups responseMode lcnv lusr victim = do +guardPreventAdminlessGroups responseMode lcnv lusr victim = + guardPreventAdminlessGroupsFor responseMode lcnv lusr (Set.singleton victim) Set.empty Set.empty + +guardPreventAdminlessGroupsFor :: + ( Member ConversationStore r, + Member (Error AdminlessConversation) r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS ('ActionDenied 'ModifyOtherConversationMember)) r, + Member (ErrorS 'InvalidOperation) r, + Member (ErrorS 'ConvMemberNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member TeamSubsystem r, + Member JobSubsystem r + ) => + RemoveMemberResponseMode -> + Local ConvId -> + Local UserId -> + Set (Qualified UserId) -> + Set (Qualified UserId) -> + Set (Qualified UserId) -> + Sem r () +guardPreventAdminlessGroupsFor responseMode lcnv lusr victims addedAdmins addedMembers = do conv <- getConversationWithError lcnv - for_ conv.metadata.cnvmTeam $ \tid -> do + when (isAdminlessCheckCandidate conv) $ for_ conv.metadata.cnvmTeam $ \tid -> do (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid - when (feature.status == FeatureStatusEnabled && isLeavingLastConversationAdmin (qUnqualified victim) conv) $ do - eligibleMembers <- eligibleAdminFallbackMembers lcnv (qUnqualified victim) conv + let victims' = Set.map qUnqualified victims + removingLastAdmin = + Set.null addedAdmins + && any + (\member -> member.convRoleName == roleNameWireAdmin && Set.member member.id_ victims') + conv.localMembers + && not + ( any + (\member -> member.convRoleName == roleNameWireAdmin && Set.notMember member.id_ victims') + conv.localMembers + ) + && not (any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers) + when (feature.status == FeatureStatusEnabled && removingLastAdmin) $ do + let addedMembersForEligibility = case responseMode of + RemoveMemberLegacyResponse -> Set.empty + RemoveMemberEligibleMembersResponse -> addedMembers + eligibleMembers <- eligibleAdminFallbackMembersFor lcnv victims' addedMembersForEligibility conv case (responseMode, eligibleMembers) of (RemoveMemberLegacyResponse, x : xs) -> do seed <- randomWord64 @@ -1208,18 +1317,210 @@ guardPreventAdminlessGroups responseMode lcnv lusr victim = do (RemoveMemberEligibleMembersResponse, _ : _) -> throw $ AdminlessConversation (fmap fst eligibleMembers) (RemoveMemberLegacyResponse, []) -> - -- FUTUREWORK: mark for deletion - pure () - (RemoveMemberEligibleMembersResponse, []) -> do - -- FUTUREWORK: mark for deletion - pure () + scheduleDeletion lcnv (Just lusr) tid feature + (RemoveMemberEligibleMembersResponse, []) -> + scheduleDeletion lcnv (Just lusr) tid feature + +scheduleDeletion :: + ( Member Now r, + Member JobSubsystem r + ) => + Local ConvId -> + Maybe (Local UserId) -> + TeamId -> + LockableFeature PreventAdminlessGroupsConfig -> + Sem r () +scheduleDeletion lcnv mlusr tid feature = do + now <- Now.get + let deletionTimeout = timeoutToNominalDiffTime feature.config.deletionTimeout + scheduledFor = addUTCTime deletionTimeout now + deletionScheduledFor = toUTCTimeMillis scheduledFor + void $ scheduleAdminlessDeletionJob mlusr tid (qUnqualified (tUntagged lcnv)) scheduledFor + for_ feature.config.reminderTimeouts $ + scheduleReminder now deletionScheduledFor deletionTimeout + where + scheduleReminder now deletionScheduledFor deletionTimeout reminderTimeoutCfg = do + let reminderTimeout = timeoutToNominalDiffTime reminderTimeoutCfg + when (reminderTimeout < deletionTimeout) $ do + let reminderAt = addUTCTime (deletionTimeout - reminderTimeout) now + void $ + scheduleAdminlessReminderJob + mlusr + tid + (qUnqualified (tUntagged lcnv)) + deletionScheduledFor + reminderTimeout + reminderAt + + timeoutToNominalDiffTime :: PreventAdminlessTimeout -> NominalDiffTime + timeoutToNominalDiffTime = + realToFrac . duration . durationLiteralValue . preventAdminlessTimeoutLiteral + +adminlessTryAutopromote :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r + ) => + Maybe (Local UserId) -> + Local ConvId -> + (StoredConversation -> LockableFeature PreventAdminlessGroupsConfig -> [(Qualified UserId, User.Name)] -> Sem r ()) -> + Sem r () +adminlessTryAutopromote mlusr lcnv altAction = do + conv <- getConversationWithError lcnv + when (isAdminlessCheckCandidate conv) $ for_ conv.metadata.cnvmTeam $ \tid -> do + (feature :: LockableFeature PreventAdminlessGroupsConfig) <- getFeatureForTeam tid + let adminExists = any (\member -> member.convRoleName == roleNameWireAdmin) conv.localMembers || any (\member -> member.convRoleName == roleNameWireAdmin) conv.remoteMembers + when (feature.status == FeatureStatusEnabled && not adminExists) $ do + eligibleMembers <- eligibleAdminFallbackMembers lcnv Nothing conv + case eligibleMembers of + x : xs -> do + seed <- randomWord64 + let autopromotionCandidates = selectAutopromotionCandidate seed feature.config.promotionStrategy (x :| xs) + update = OtherMemberUpdate (Just roleNameWireAdmin) + for_ autopromotionCandidates $ \candidate -> do + E.setOtherMember lcnv candidate update + case mlusr of + Just lusr -> + void $ + sendConversationActionNotifications + (sing @'ConversationMemberUpdateTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + (ConversationMemberUpdate candidate update) + def + Nothing -> do + now <- Now.get + Notify.pushSystemEvent + Nothing + ( SystemEvent + (tUntagged lcnv) + Nothing + now + conv.metadata.cnvmTeam + (EdSystemMemberUpdate (memberUpdateData candidate update)) + ) + (Set.fromList (map (.id_) conv.localMembers)) + [] -> altAction conv feature eligibleMembers where - -- Use eight random bytes and fold them into a big-endian Word64. This keeps - -- the helper small, deterministic under tests, and free of extra Random API. - randomWord64 :: (Member Random r) => Sem r Word64 - randomWord64 = BS.foldl' step 0 <$> Random.bytes 8 - where - step acc byte = shiftL acc 8 .|. fromIntegral byte + memberUpdateData candidate memberUpdate' = + MemberUpdateData + { misTarget = candidate, + misOtrMutedStatus = Nothing, + misOtrMutedRef = Nothing, + misOtrArchived = Nothing, + misOtrArchivedRef = Nothing, + misHidden = Nothing, + misHiddenRef = Nothing, + misConvRoleName = omuConvRoleName memberUpdate' + } + +adminlessAutopromoteOrDelete :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member ProposalStore r, + Member CodeStore r, + Member TinyLog r + ) => + Maybe (Local UserId) -> + Local ConvId -> + Sem r () +adminlessAutopromoteOrDelete mlusr lcnv = adminlessTryAutopromote mlusr lcnv orAlternativelyDeleteConv + where + orAlternativelyDeleteConv conv _ _ = + if shouldSkipSystemAdminlessDeletion mlusr conv + then logSkippedSystemAdminlessDeletion "deletion" conv + else do + removeConversation (qualifyAs lcnv conv) + case mlusr of + Just lusr -> + void $ + sendConversationActionNotifications + (sing @'ConversationDeleteTag) + (tUntagged lusr) + False + Nothing + (qualifyAs lcnv conv) + (convBotsAndMembers conv) + () + def + Nothing -> do + now <- Now.get + Notify.pushSystemEvent + Nothing + (SystemEvent (tUntagged lcnv) Nothing now conv.metadata.cnvmTeam EdSystemConvDelete) + (Set.fromList (map (.id_) conv.localMembers)) + +adminlessAutopromoteOrSendReminder :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member (Error FederationError) r, + Member BrigAPIAccess r, + Member Random r, + Member NotificationSubsystem r, + Member Now r, + Member E.ExternalAccess r, + Member BackendNotificationQueueAccess r, + Member FeaturesConfigSubsystem r, + Member TinyLog r + ) => + Maybe (Local UserId) -> + Local ConvId -> + UTCTimeMillis -> + Sem r () +adminlessAutopromoteOrSendReminder mlusr lcnv deletionScheduledFor = adminlessTryAutopromote mlusr lcnv orAlternativelySendReminder + where + orAlternativelySendReminder conv _ _ = + if shouldSkipSystemAdminlessDeletion mlusr conv + then logSkippedSystemAdminlessDeletion "reminder" conv + else do + now <- Now.get + case mlusr of + Just lusr -> do + let event = + Event + (tUntagged lcnv) + Nothing + (EventFromUser (tUntagged lusr)) + now + (conv.metadata.cnvmTeam) + (EdAdminlessReminder (AdminlessReminder deletionScheduledFor)) + pushConversationEvent Nothing conv event (qualifyAs lcnv (map (.id_) conv.localMembers)) [] + Nothing -> + Notify.pushSystemEvent + Nothing + ( SystemEvent + (tUntagged lcnv) + Nothing + now + conv.metadata.cnvmTeam + (EdSystemAdminlessReminder (AdminlessReminder deletionScheduledFor)) + ) + (Set.fromList (map (.id_) conv.localMembers)) + +-- Use eight random bytes and fold them into a big-endian Word64. This keeps +-- the helper small, deterministic under tests, and free of extra Random API. +randomWord64 :: (Member Random r) => Sem r Word64 +randomWord64 = BS.foldl' step 0 <$> Random.bytes 8 + where + step acc byte = shiftL acc 8 .|. fromIntegral byte isLeavingLastConversationAdmin :: UserId -> StoredConversation -> Bool isLeavingLastConversationAdmin leavingUser conv = @@ -1234,17 +1535,38 @@ isLeavingLastConversationAdmin leavingUser conv = eligibleAdminFallbackMembers :: (Member BrigAPIAccess r) => Local ConvId -> - UserId -> + Maybe UserId -> + StoredConversation -> + Sem r [(Qualified UserId, User.Name)] +eligibleAdminFallbackMembers lcnv mLeavingUser conv = do + eligibleAdminFallbackMembersFor lcnv (maybe Set.empty Set.singleton mLeavingUser) Set.empty conv + +eligibleAdminFallbackMembersFor :: + (Member BrigAPIAccess r) => + Local ConvId -> + Set UserId -> + Set (Qualified UserId) -> StoredConversation -> Sem r [(Qualified UserId, User.Name)] -eligibleAdminFallbackMembers lcnv leavingUser conv = do - users <- Brig.getUsers (map (.id_) (filter ((/= leavingUser) . (.id_)) conv.localMembers)) +eligibleAdminFallbackMembersFor lcnv leavingUsers addedUsers conv = do + let existingCandidates = + [ (Qualified member.id_ (tDomain lcnv), member.id_) + | member <- conv.localMembers, + Set.notMember member.id_ leavingUsers + ] + addedLocalCandidates = + [ (user, qUnqualified user) + | user <- Set.toList addedUsers, + qDomain user == tDomain lcnv + ] + candidates = existingCandidates <> addedLocalCandidates + candidateIds = Set.toList (Set.fromList (map snd candidates)) + users <- Brig.getUsers candidateIds let usersById = Map.fromList [(User.userId u, u) | u <- users] pure - [ (tUntagged (qualifyAs lcnv member.id_), u.userDisplayName) - | member <- conv.localMembers, - member.id_ /= leavingUser, - Just u <- [Map.lookup member.id_ usersById], + [ (qualifiedId, u.userDisplayName) + | (qualifiedId, candidateId) <- candidates, + Just u <- [Map.lookup candidateId usersById], isEligibleUser u ] where @@ -1268,7 +1590,8 @@ deleteUserFromTeamConversationsImpl :: Member E.ExternalAccess r, Member Now r, Member Random r, - Member TeamSubsystem r + Member TeamSubsystem r, + Member JobSubsystem r ) => Local UserId -> Maybe ConnId -> @@ -1379,7 +1702,8 @@ removeMemberFromLocalConv :: Member (ErrorS ConvMemberNotFound) r, Member (Error AdminlessConversation) r, Member FeaturesConfigSubsystem r, - Member BrigAPIAccess r + Member BrigAPIAccess r, + Member JobSubsystem r ) => RemoveMemberResponseMode -> Local ConvId -> diff --git a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs index b6e3c61c5ed..4e8b7dc27b8 100644 --- a/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs +++ b/libs/wire-subsystems/src/Wire/ConversationSubsystem/Util.hs @@ -146,13 +146,12 @@ ensureConnectedOrSameTeam lusr others = do ensureConnectedToLocalsOrSameTeam lusr locals ensureConnectedToRemotes lusr remotes --- | Check that the given user is part of the same team(s) as the other users --- OR that there is a connection (either direct or implicit via team --- collaborations.) +-- | Check that the given users are connected to `u`, or that `u` is +-- member of the same team, or that there is a collaboration. -- --- Team members are always considered connected, so we only check --- 'ensureConnected' for non-team-members of the _given_ user. Implicit --- connections are created per team, so we count them as team membership here. +-- A collaboration exists between users `u` and `v` if either `u` is +-- collaborating with `v`s team and has "implicit connection" +-- permissions, or that `v` is collaborating in `u`s team. ensureConnectedToLocalsOrSameTeam :: ( Member BrigAPIAccess r, Member (ErrorS 'NotConnected) r, @@ -165,26 +164,29 @@ ensureConnectedToLocalsOrSameTeam :: Sem r () ensureConnectedToLocalsOrSameTeam _ [] = pure () ensureConnectedToLocalsOrSameTeam (tUnqualified -> u) uids = do + -- own team uTeams <- getUserTeams u + -- teams with which `u` collaborates icTeams <- getUserCollaborationTeams + -- users collaborating with `u`s team icUsers <- getTeamCollaborators uTeams - -- We collect all the relevant uids from same teams as the origin user + -- Subset of uids from same team as `u` (the user who wants to connect) sameTeamUids <- forM (uTeams `union` icTeams) $ \team -> fmap (view Mem.userId) <$> TeamSubsystem.internalSelectTeamMembers team uids - -- Do not check connections for users that are on the same team + -- Do not check connections for team members and collaborators ensureConnectedToLocals u ((uids \\ join sameTeamUids) \\ icUsers) where - -- Teams in which the user who wants to reach out is member with - -- `ImplicitConnection` permission. + -- Teams in which `u` (the user who wants to connect) is + -- collaborator with `ImplicitConnection` permission. getUserCollaborationTeams :: (Member TeamCollaboratorsSubsystem r') => Sem r' [TeamId] getUserCollaborationTeams = gTeam <$$> (filter (flip hasPermission CollaboratorPermission.ImplicitConnection) <$> internalGetTeamCollaborations u) - -- We do not check the permissions of team collaborators if a user tries to - -- reach out to them (if they are in the same team.) The reasoning behind - -- this is that team collaborators have implicitly agreed to be - -- collaborated with. + -- We do not check the permissions of team collaborators if a user + -- tries to reach out to them (if they are in the same team.) The + -- reasoning behind this is that team collaborators have + -- implicitly agreed to be collaborated with. getTeamCollaborators :: (Member TeamCollaboratorsSubsystem r') => [TeamId] -> Sem r' [UserId] getTeamCollaborators teams = gUser <$$> internalGetTeamCollaboratorsWithIds (Set.fromList teams) (Set.fromList uids) diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode.hs b/libs/wire-subsystems/src/Wire/DefaultSsoStore.hs similarity index 94% rename from services/spar/src/Spar/Sem/DefaultSsoCode.hs rename to libs/wire-subsystems/src/Wire/DefaultSsoStore.hs index 5949f859705..ed33349e978 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode.hs +++ b/libs/wire-subsystems/src/Wire/DefaultSsoStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.DefaultSsoCode +module Wire.DefaultSsoStore ( DefaultSsoCode (..), get, store, @@ -28,7 +28,7 @@ where import Imports import Polysemy import Polysemy.Check (deriveGenericK) -import qualified SAML2.WebSSO as SAML +import SAML2.WebSSO qualified as SAML data DefaultSsoCode m a where Get :: DefaultSsoCode m (Maybe SAML.IdPId) diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode/Cassandra.hs b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Cassandra.hs similarity index 89% rename from services/spar/src/Spar/Sem/DefaultSsoCode/Cassandra.hs rename to libs/wire-subsystems/src/Wire/DefaultSsoStore/Cassandra.hs index 680c8c0ec7c..b1a5f80c538 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Cassandra.hs @@ -19,7 +19,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.DefaultSsoCode.Cassandra +module Wire.DefaultSsoStore.Cassandra ( defaultSsoCodeToCassandra, ) where @@ -27,10 +27,9 @@ where import Cassandra import Imports import Polysemy -import qualified SAML2.WebSSO.Types as SAML -import Spar.Data.Instances () -import Spar.Sem.DefaultSsoCode -import {- instance Cql SAML.IdPId -} Wire.DomainRegistrationStore.Cassandra () +import SAML2.WebSSO.Types qualified as SAML +import Wire.DefaultSsoStore +import Wire.DomainRegistrationStore.Cassandra () defaultSsoCodeToCassandra :: forall m r a. @@ -65,7 +64,7 @@ storeDefaultSsoCode :: storeDefaultSsoCode idpId = do -- there is a race condition here which means there could potentially be more -- than one entry (violating invariant 2). - -- However, the SELECT query will deterministally pick one of them due to the + -- However, the SELECT query will deterministically pick one of them due to the -- `ORDER BY` clause. The others will get removed by `deleteDefaultSsoCode` -- the next time this function is called (as it removes all entries). deleteDefaultSsoCode diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode/Mem.hs b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Mem.hs similarity index 90% rename from services/spar/src/Spar/Sem/DefaultSsoCode/Mem.hs rename to libs/wire-subsystems/src/Wire/DefaultSsoStore/Mem.hs index c684eaa1a66..f9d9d4d5268 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode/Mem.hs +++ b/libs/wire-subsystems/src/Wire/DefaultSsoStore/Mem.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.DefaultSsoCode.Mem +module Wire.DefaultSsoStore.Mem ( defaultSsoCodeToMem, ) where @@ -25,8 +25,8 @@ where import Imports import Polysemy import Polysemy.State (get, put, runState) -import qualified SAML2.WebSSO as SAML -import Spar.Sem.DefaultSsoCode (DefaultSsoCode (..)) +import SAML2.WebSSO qualified as SAML +import Wire.DefaultSsoStore (DefaultSsoCode (..)) defaultSsoCodeToMem :: Sem (DefaultSsoCode ': r) a -> Sem r (Maybe SAML.IdPId, a) defaultSsoCodeToMem = (runState Nothing .) $ diff --git a/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs b/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs index 26a7d5de941..5047d4b61e2 100644 --- a/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/DomainRegistrationStore/Migration.hs @@ -27,7 +27,8 @@ import Data.Conduit.List qualified as C import Data.Domain import Data.Id import Database.CQL.Protocol (Record (asRecord), TupleType) -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) +import Hasql.Pool.Extended qualified as Hasql import Imports hiding (lookup) import Polysemy import Polysemy.Async @@ -180,7 +181,7 @@ handleRegistrationErrors :: Member TinyLog r ) => ByteString -> - (Sem (Error MigrationLockError : Error Hasql.UsageError : r) ()) -> + (Sem (Error MigrationLockError : Error UsageError : r) ()) -> Sem r () handleRegistrationErrors key action = do eithErr <- runError (runError action) diff --git a/libs/wire-subsystems/src/Wire/EmailSending/Options.hs b/libs/wire-subsystems/src/Wire/EmailSending/Options.hs new file mode 100644 index 00000000000..8fe5f1f00b9 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/EmailSending/Options.hs @@ -0,0 +1,78 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Shared email-transport option types used by both Brig and Galley. Moved +-- here from "Brig.Options" so both services parse the same SES\/SMTP shape. +module Wire.EmailSending.Options + ( EmailAWSOpts (..), + EmailSMTPCredentials (..), + EmailSMTPOpts (..), + EmailOpts (..), + ) +where + +import Data.Aeson (FromJSON, parseJSON) +import Imports +import Util.Options (AWSEndpoint, Endpoint, FilePathSecrets) +import Wire.EmailSending.SMTP (SMTPConnType (..)) + +data EmailAWSOpts = EmailAWSOpts + { -- | Event feedback queue for SES + -- (e.g. for email bounces and complaints) + sesQueue :: !Text, + -- | AWS SES endpoint + sesEndpoint :: !AWSEndpoint + } + deriving (Show, Generic) + +instance FromJSON EmailAWSOpts + +data EmailSMTPCredentials = EmailSMTPCredentials + { -- | Username to authenticate + -- against the SMTP server + smtpUsername :: !Text, + -- | File containing password to + -- authenticate against the SMTP server + smtpPassword :: !FilePathSecrets + } + deriving (Show, Generic) + +instance FromJSON EmailSMTPCredentials + +data EmailSMTPOpts = EmailSMTPOpts + { -- | Hostname of the SMTP server to connect to + smtpEndpoint :: !Endpoint, + smtpCredentials :: !(Maybe EmailSMTPCredentials), + -- | Which type of connection to use + -- against the SMTP server {tls,ssl,plain} + smtpConnType :: !SMTPConnType + } + deriving (Show, Generic) + +instance FromJSON EmailSMTPOpts + +data EmailOpts + = EmailAWS EmailAWSOpts + | EmailSMTP EmailSMTPOpts + deriving (Show, Generic) + +instance FromJSON EmailOpts where + parseJSON o = + EmailAWS + <$> parseJSON o + <|> EmailSMTP + <$> parseJSON o diff --git a/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs b/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs index 2a2550cfd35..11d36455eb2 100644 --- a/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs +++ b/libs/wire-subsystems/src/Wire/EmailSubsystem/Template.hs @@ -39,6 +39,7 @@ import System.Logger (field, msg, val) import Wire.API.Locale import Wire.API.User.EmailAddress (EmailAddress) import Wire.EmailSubsystem.Templates.Team +import Wire.EmailSubsystem.Templates.User -- | Lookup a localised item from a 'Localised' structure. forLocale :: @@ -252,3 +253,99 @@ loadTeamTemplates tOptions templatesDir defLocale sender = readLocalesDir defLoc tExistingUrl = template tOptions.tExistingUserInvitationUrl readTemplate' = readTemplateWithDefault templatesDir defLocale "team" readText' = readTextWithDefault templatesDir defLocale "team" + +-- | URL templates needed to render the user email templates. These are plain +-- 'Text' 'Data.Text.Template.template' strings, mirroring the corresponding +-- Brig configuration fields; 'loadUserTemplates' turns them into 'Template's. +data UserTemplateOpts = UserTemplateOpts + { -- | Activation URL template + activationUrl :: !Text, + -- | Team activation URL template + teamActivationUrl :: !Text, + -- | Password reset URL template + passwordResetUrl :: !Text, + -- | Deletion URL template + deletionUrl :: !Text + } + deriving stock (Show, Generic) + +loadUserTemplates :: UserTemplateOpts -> FilePath -> Locale -> EmailAddress -> IO (Localised UserTemplates) +loadUserTemplates opts templatesDir defLocale sender = readLocalesDir defLocale templatesDir "user" $ \fp -> + UserTemplates + <$> ( VerificationEmailTemplate activationUrl + <$> readTemplate' fp "email/verification-subject.txt" + <*> readTemplate' fp "email/verification.txt" + <*> readTemplate' fp "email/verification.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( ActivationEmailTemplate activationUrl + <$> readTemplate' fp "email/activation-subject.txt" + <*> readTemplate' fp "email/activation.txt" + <*> readTemplate' fp "email/activation.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( ActivationEmailTemplate activationUrl + <$> readTemplate' fp "email/update-subject.txt" + <*> readTemplate' fp "email/update.txt" + <*> readTemplate' fp "email/update.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( TeamActivationEmailTemplate teamActivationUrl + <$> readTemplate' fp "email/team-activation-subject.txt" + <*> readTemplate' fp "email/team-activation.txt" + <*> readTemplate' fp "email/team-activation.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( PasswordResetEmailTemplate passwordResetUrl + <$> readTemplate' fp "email/password-reset-subject.txt" + <*> readTemplate' fp "email/password-reset.txt" + <*> readTemplate' fp "email/password-reset.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( DeletionEmailTemplate deletionUrl + <$> readTemplate' fp "email/deletion-subject.txt" + <*> readTemplate' fp "email/deletion.txt" + <*> readTemplate' fp "email/deletion.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( NewClientEmailTemplate + <$> readTemplate' fp "email/new-client-subject.txt" + <*> readTemplate' fp "email/new-client.txt" + <*> readTemplate' fp "email/new-client.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( SecondFactorVerificationEmailTemplate + <$> readTemplate' fp "email/verification-login-subject.txt" + <*> readTemplate' fp "email/verification-login.txt" + <*> readTemplate' fp "email/verification-login.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( SecondFactorVerificationEmailTemplate + <$> readTemplate' fp "email/verification-scim-token-subject.txt" + <*> readTemplate' fp "email/verification-scim-token.txt" + <*> readTemplate' fp "email/verification-scim-token.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + <*> ( SecondFactorVerificationEmailTemplate + <$> readTemplate' fp "email/verification-delete-team-subject.txt" + <*> readTemplate' fp "email/verification-delete-team.txt" + <*> readTemplate' fp "email/verification-delete-team.html" + <*> pure sender + <*> readText' fp "email/sender.txt" + ) + where + activationUrl = template opts.activationUrl + teamActivationUrl = template opts.teamActivationUrl + passwordResetUrl = template opts.passwordResetUrl + deletionUrl = template opts.deletionUrl + readTemplate' = readTemplateWithDefault templatesDir defLocale "user" + readText' = readTextWithDefault templatesDir defLocale "user" diff --git a/libs/wire-subsystems/src/Wire/Error.hs b/libs/wire-subsystems/src/Wire/Error.hs index b526bb6c9da..cab57eb1887 100644 --- a/libs/wire-subsystems/src/Wire/Error.hs +++ b/libs/wire-subsystems/src/Wire/Error.hs @@ -35,7 +35,6 @@ import Network.HTTP.Types import Network.Wai import Network.Wai.Utilities import Network.Wai.Utilities.Error qualified as Wai -import Network.Wai.Utilities.Exception import Network.Wai.Utilities.JSONResponse import Network.Wai.Utilities.Server import Servant (ServerError (..)) @@ -82,14 +81,14 @@ httpErrorToJSONResponse e@(RichError werr _ headers) = postgresUsageErrorToHttpError :: UsageError -> HttpError postgresUsageErrorToHttpError err = case err of - SessionUsageError _se -> + SessionError _se -> -- FUTUREWORK: should this case should be more nuanced? eg., if a foreign key is dangling, should we -- return "404 not found", not "database crashed"? -- The problem is that the SessionError is not typed to easily be parsed -- To prevent foreign key errors we should check the foreign key constraints before inserting - StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> displayExceptionNoBacktrace err)) - ConnectionUsageError _ -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> displayExceptionNoBacktrace err)) - AcquisitionTimeoutUsageError -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> displayExceptionNoBacktrace err)) + StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> show err)) + ConnectionError _ -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> show err)) + AcquisitionTimeoutUsageError -> StdError (Wai.mkError status500 "server-error" (LT.pack $ "postgres: " <> show err)) -- | Extract the wai error from an HttpError and convert into a -- servant error. `RichError` extra data is discarded! diff --git a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs index 70936a806e0..9d466d46b67 100644 --- a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Interpreter.hs @@ -3,6 +3,23 @@ {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.FeaturesConfigSubsystem.Interpreter where import Control.Error (hush) diff --git a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs index 023035e8c11..f06b563bff1 100644 --- a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs +++ b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Types.hs @@ -1,6 +1,24 @@ {-# LANGUAGE ConstraintKinds #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE UndecidableSuperClasses #-} +{-# OPTIONS_GHC -Wno-deprecations #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . module Wire.FeaturesConfigSubsystem.Types where diff --git a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs index 9314fda02a2..ccf990522a0 100644 --- a/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs +++ b/libs/wire-subsystems/src/Wire/FeaturesConfigSubsystem/Utils.hs @@ -1,5 +1,22 @@ {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.FeaturesConfigSubsystem.Utils where import Imports diff --git a/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs b/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs index 34065d6d85e..31208b8e9ae 100644 --- a/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs +++ b/libs/wire-subsystems/src/Wire/IdPConfigStore/Orphans.hs @@ -1,5 +1,22 @@ {-# OPTIONS_GHC -fno-warn-orphans #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.IdPConfigStore.Orphans where import Cassandra as Cas diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore.hs b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore.hs similarity index 94% rename from services/spar/src/Spar/Sem/IdPRawMetadataStore.hs rename to libs/wire-subsystems/src/Wire/IdPRawMetadataStore.hs index d6425df9ca7..db86bcad1ad 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore.hs +++ b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.IdPRawMetadataStore +module Wire.IdPRawMetadataStore ( IdPRawMetadataStore (..), store, get, @@ -28,7 +28,7 @@ where import Imports import Polysemy import Polysemy.Check (deriveGenericK) -import qualified SAML2.WebSSO as SAML +import SAML2.WebSSO qualified as SAML data IdPRawMetadataStore m a where Store :: SAML.IdPId -> Text -> IdPRawMetadataStore m () diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Cassandra.hs similarity index 93% rename from services/spar/src/Spar/Sem/IdPRawMetadataStore/Cassandra.hs rename to libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Cassandra.hs index 812442d958a..8325d739382 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Cassandra.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.IdPRawMetadataStore.Cassandra +module Wire.IdPRawMetadataStore.Cassandra ( idpRawMetadataStoreToCassandra, ) where @@ -27,10 +27,9 @@ import Cassandra as Cas import Control.Lens import Imports import Polysemy -import qualified SAML2.WebSSO as SAML -import Spar.Data.Instances () -import Spar.Sem.IdPRawMetadataStore +import SAML2.WebSSO qualified as SAML import {- instance Cql SAML.IdPId -} Wire.DomainRegistrationStore.Cassandra () +import Wire.IdPRawMetadataStore idpRawMetadataStoreToCassandra :: forall m r a. diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Mem.hs b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Mem.hs similarity index 88% rename from services/spar/src/Spar/Sem/IdPRawMetadataStore/Mem.hs rename to libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Mem.hs index ace943c0a3e..d95950a3dc8 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Mem.hs +++ b/libs/wire-subsystems/src/Wire/IdPRawMetadataStore/Mem.hs @@ -17,14 +17,14 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.IdPRawMetadataStore.Mem (idpRawMetadataStoreToMem, RawState) where +module Wire.IdPRawMetadataStore.Mem (idpRawMetadataStoreToMem, RawState) where -import qualified Data.Map as M +import Data.Map qualified as M import Imports import Polysemy import Polysemy.State (State, gets, modify, runState) -import qualified SAML2.WebSSO.Types as SAML -import Spar.Sem.IdPRawMetadataStore +import SAML2.WebSSO.Types qualified as SAML +import Wire.IdPRawMetadataStore type RawState = Map SAML.IdPId Text diff --git a/libs/wire-subsystems/src/Wire/IdPSubsystem.hs b/libs/wire-subsystems/src/Wire/IdPSubsystem.hs index c3261c63900..2d79b707cd3 100644 --- a/libs/wire-subsystems/src/Wire/IdPSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/IdPSubsystem.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.IdPSubsystem where import Imports diff --git a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs index 50af85ad305..cb8b0a68921 100644 --- a/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/IdPSubsystem/Interpreter.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.IdPSubsystem.Interpreter ( interpretIdPSubsystem, IdPSubsystemError (..), @@ -16,6 +33,7 @@ import Polysemy import Polysemy.Error import SAML2.WebSSO qualified as SAML import System.Logger.Message qualified as Log +import Wire.API.Routes.Public (ZHostValue) import Wire.API.User import Wire.API.User.IdentityProvider qualified as IP import Wire.BrigAPIAccess @@ -82,7 +100,7 @@ getSsoCodeByEmailImpl :: Member GalleyAPIAccess r, Member IdPConfigStore r ) => - Bool -> Maybe Text -> EmailAddress -> Sem r (Maybe SAML.IdPId) + Bool -> Maybe ZHostValue -> EmailAddress -> Sem r (Maybe SAML.IdPId) getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = do if not enableIdPByEmailDiscovery @@ -92,7 +110,7 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = case users of [] -> pure Nothing [user] -> do - if isScimOrSsoUser user + if isSsoUser user then do mbTeam <- getTeamId (userId user) case mbTeam of @@ -111,9 +129,11 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = userIdToText :: Qualified UserId -> Text userIdToText uid = idToText (qUnqualified uid) <> "@" <> domainText (qDomain uid) - isScimOrSsoUser :: User -> Bool - isScimOrSsoUser user = - userManagedBy user == ManagedByScim && isJust (userSSOId user) + -- This used to check if the user is SCIM AND SSO! The RFC ("2025-05-12 + -- RFC: Default SSO flow for team by host domain") is ambiguous about this. + -- The customer currently provisions non-SCIM, so this fits their usecase. + isSsoUser :: User -> Bool + isSsoUser = isJust . userSSOId findIdPByDomain :: (Member (Logger (Log.Msg -> Log.Msg)) r) => [IP.IdP] -> Sem r (Maybe SAML.IdPId) findIdPByDomain [] = pure Nothing @@ -124,6 +144,6 @@ getSsoCodeByEmailImpl enableIdPByEmailDiscovery mbHost email = when (length matches > 1) $ Logger.warn $ Log.msg @Text "Found more than one IdP config for domain" - . Log.field "domain" (fromMaybe "None" mbHost) + . Log.field "domain" (maybe "None" domainText mbHost) . Log.field "idpIds" (intercalate "," $ (UUID.toString . SAML.fromIdPId) <$> matches) pure $ listToMaybe matches diff --git a/libs/wire-subsystems/src/Wire/InvitationStore.hs b/libs/wire-subsystems/src/Wire/InvitationStore.hs index 54873c400f0..e875677cce5 100644 --- a/libs/wire-subsystems/src/Wire/InvitationStore.hs +++ b/libs/wire-subsystems/src/Wire/InvitationStore.hs @@ -96,6 +96,9 @@ data InvitationStore :: Effect where LookupInvitation :: TeamId -> InvitationId -> InvitationStore m (Maybe StoredInvitation) LookupInvitationByCode :: InvitationCode -> InvitationStore m (Maybe StoredInvitation) LookupInvitationsByEmail :: EmailAddress -> InvitationStore m [StoredInvitation] + InsertPendingScimUser :: TeamId -> EmailAddress -> UserId -> InvitationStore m () + LookupPendingScimUsers :: TeamId -> EmailAddress -> InvitationStore m [UserId] + DeletePendingScimUser :: TeamId -> EmailAddress -> UserId -> InvitationStore m () -- | Range is page size, it defaults to 100 LookupInvitationsPaginated :: Maybe (Range 1 500 Int32) -> TeamId -> Maybe InvitationId -> InvitationStore m (PaginatedResult [StoredInvitation]) CountInvitations :: TeamId -> InvitationStore m Int64 diff --git a/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs index e3ecf2d63bb..fbafe1e291a 100644 --- a/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/InvitationStore/Cassandra.hs @@ -44,6 +44,9 @@ interpretInvitationStoreToCassandra casClient = InsertInvitation newInv timeout -> embed $ insertInvitationImpl newInv timeout LookupInvitation tid iid -> embed $ lookupInvitationImpl tid iid LookupInvitationsByEmail email -> embed $ lookupInvitationsByEmailImpl email + InsertPendingScimUser tid email uid -> embed $ insertPendingScimUserImpl tid email uid + LookupPendingScimUsers tid email -> embed $ lookupPendingScimUsersImpl tid email + DeletePendingScimUser tid email uid -> embed $ deletePendingScimUserImpl tid email uid LookupInvitationByCode code -> embed $ lookupInvitationByCodeImpl code LookupInvitationsPaginated mSize tid miid -> embed $ lookupInvitationsPaginatedImpl mSize tid miid CountInvitations tid -> embed $ countInvitationsImpl tid @@ -152,6 +155,36 @@ lookupInvitationsByEmailImpl email = do SELECT team, role, id, created_at, created_by, email, name, code FROM team_invitation WHERE team = ? AND id = ? |] +insertPendingScimUserImpl :: TeamId -> EmailAddress -> UserId -> Client () +insertPendingScimUserImpl team email uid = + retry x5 $ write cql (params LocalQuorum (team, email, uid)) + where + cql :: PrepQuery W (TeamId, EmailAddress, UserId) () + cql = + [sql| + INSERT INTO team_scim_pending_user_email (team, email, user) VALUES (?, ?, ?) + |] + +lookupPendingScimUsersImpl :: TeamId -> EmailAddress -> Client [UserId] +lookupPendingScimUsersImpl team email = + map runIdentity <$> retry x1 (query cql (params LocalQuorum (team, email))) + where + cql :: PrepQuery R (TeamId, EmailAddress) (Identity UserId) + cql = + [sql| + SELECT user FROM team_scim_pending_user_email WHERE team = ? AND email = ? + |] + +deletePendingScimUserImpl :: TeamId -> EmailAddress -> UserId -> Client () +deletePendingScimUserImpl team email uid = + retry x5 $ write cql (params LocalQuorum (team, email, uid)) + where + cql :: PrepQuery W (TeamId, EmailAddress, UserId) () + cql = + [sql| + DELETE FROM team_scim_pending_user_email WHERE team = ? AND email = ? AND user = ? + |] + lookupInvitationImpl :: TeamId -> InvitationId -> Client (Maybe StoredInvitation) lookupInvitationImpl tid iid = fmap asRecord diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem.hs b/libs/wire-subsystems/src/Wire/JobSubsystem.hs new file mode 100644 index 00000000000..208aa912600 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem.hs @@ -0,0 +1,47 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem + ( JobSubsystemConfig (..), + JobSubsystem (..), + scheduleAdminlessSetupJob, + scheduleAdminlessDeletionJob, + scheduleAdminlessReminderJob, + cancelAdminlessJobsForTeam, + ) +where + +import Data.Id +import Data.Json.Util (UTCTimeMillis) +import Data.Qualified +import Data.Time.Clock (NominalDiffTime, UTCTime) +import Imports +import Polysemy + +data JobSubsystemConfig = JobSubsystemConfig + { jobSubsystemSchemaName :: Text + } + +data JobSubsystem m a where + ScheduleAdminlessSetupJob :: Maybe (Local UserId) -> TeamId -> JobSubsystem m () + ScheduleAdminlessDeletionJob :: Maybe (Local UserId) -> TeamId -> ConvId -> UTCTime -> JobSubsystem m () + ScheduleAdminlessReminderJob :: Maybe (Local UserId) -> TeamId -> ConvId -> UTCTimeMillis -> NominalDiffTime -> UTCTime -> JobSubsystem m () + CancelAdminlessJobsForTeam :: TeamId -> JobSubsystem m () + +makeSem ''JobSubsystem diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs new file mode 100644 index 00000000000..5c7ff2449e7 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/ArbiterAdapter.hs @@ -0,0 +1,206 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE TypeFamilies #-} + +-- | Adapter that lets Arbiter run against wire-server's shared Hasql pool. +-- +-- The pool we want to reuse is 'HasqlPoolExt.Pool'. Internally that is backed +-- by @Data.Pool (Either ConnectionError Connection)@, but the underlying +-- resource pool is intentionally opaque in @hasql-resource-pool@. The only +-- missing piece is therefore a small exported helper from that package that +-- borrows one live 'Connection' for the duration of a callback. +module Wire.JobSubsystem.ArbiterAdapter where + +import Arbiter.Core.Codec (Params, RowCodec) +import Arbiter.Core.Exceptions (throwInternal) +import Arbiter.Core.MonadArbiter (MonadArbiter (..), Query (..)) +import Arbiter.Core.QueueRegistry (JobPayloadRegistry) +import Arbiter.Core.Sql.Query (numberPlaceholders) +import Arbiter.Hasql.Decode qualified as Decode +import Arbiter.Hasql.Encode qualified as Encode +import Control.Exception (mask, onException, try) +import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow) +import Control.Monad.Reader +import Data.Misc (durationToCeilingSeconds) +import Data.Text qualified as T +import Hasql.Connection qualified as HasqlConn +import Hasql.Decoders qualified as Decoders +import Hasql.Encoders qualified as Encoders +import Hasql.Pool qualified as HasqlPool +import Hasql.Pool.Extended qualified as HasqlPoolExt +import Hasql.Session qualified as Session +import Hasql.Statement qualified as Statement +import Imports + +data WireArbiterEnv = WireArbiterEnv + { schemaName :: Text, + connectionPool :: HasqlPoolExt.Pool, + activeConn :: Maybe HasqlConn.Connection, + transactionDepth :: Int + } + +mkNewWireArbiterEnv :: Text -> HasqlPoolExt.Pool -> WireArbiterEnv +mkNewWireArbiterEnv schema pool = + WireArbiterEnv + { schemaName = schema, + connectionPool = pool, + activeConn = Nothing, + transactionDepth = 0 + } + +newtype WireArbiter (registry :: JobPayloadRegistry) a = WireArbiter + { unWireArbiter :: ReaderT WireArbiterEnv IO a + } + deriving newtype + ( Functor, + Applicative, + Monad, + MonadCatch, + MonadIO, + MonadMask, + MonadReader WireArbiterEnv, + MonadThrow, + MonadUnliftIO + ) + +runWireArbiter :: WireArbiterEnv -> WireArbiter registry a -> IO a +runWireArbiter env (WireArbiter action) = runReaderT action env + +instance MonadArbiter (WireArbiter registry) where + type RegistryOf (WireArbiter registry) = registry + type Handler (WireArbiter registry) jobs result = HasqlConn.Connection -> jobs -> WireArbiter registry result + getSchema = asks schemaName + + executeQuery (Query sql params codec) = do + env <- ask + withConn env $ \conn -> + runQueryStatement False conn sql params codec + + executeQueryPrepared (Query sql params codec) = do + env <- ask + withConn env $ \conn -> + runQueryStatement True conn sql params codec + + executeStatement (Query sql params _) = do + env <- ask + withConn env $ \conn -> + runExecStatement conn sql params + + withDbTransaction action = do + env <- ask + case activeConn env of + Nothing -> withRunInIO $ \run -> + run $ withPoolConnection env.connectionPool $ \conn -> run (beginCommitOrRollback conn action) + Just conn + | transactionDepth env <= 0 -> beginCommitOrRollback conn action + | otherwise -> beginSavepointTransaction conn action + + runHandlerWithConnection handler jobs = do + env <- ask + case activeConn env of + Just conn -> handler conn jobs + Nothing -> throwInternal "runHandlerWithConnection: no active connection" + + -- Wire's shared pool is already used for all Arbiter database work. Keep + -- workers poll-only so they do not pin an additional PostgreSQL connection + -- for LISTEN/NOTIFY. + getListener = pure Nothing + +withConn :: WireArbiterEnv -> (HasqlConn.Connection -> IO a) -> WireArbiter registry a +withConn env f = + case activeConn env of + Just conn -> liftIO $ f conn + Nothing -> withPoolConnection env.connectionPool f + +-- | Borrow a live connection from wire-server's shared pool. +withPoolConnection :: HasqlPoolExt.Pool -> (HasqlConn.Connection -> IO a) -> WireArbiter registry a +withPoolConnection pool f = do + result <- + liftIO $ + HasqlPool.withConnectionWithPoolAcquisitionTimeout + (durationToCeilingSeconds pool.poolAcquisitionTimeout) + pool.rawPool + (fmap Right . f) + case result of + Right x -> pure x + Left HasqlPool.AcquisitionTimeoutUsageError -> do + liftIO $ HasqlPoolExt.recordHasqlPoolAcquisitionTimeout pool.metrics + throwInternal "hasql pool acquisition timeout" + Left (HasqlPool.ConnectionError err) -> do + liftIO $ HasqlPoolExt.recordHasqlPoolConnectionFailure pool.metrics + throwInternal $ "hasql connection error: " <> T.pack (show err) + Left (HasqlPool.SessionError err) -> do + liftIO $ HasqlPoolExt.recordHasqlPoolSessionFailure pool.metrics + throwInternal $ "hasql session error: " <> T.pack (show err) + +runQueryStatement :: Bool -> HasqlConn.Connection -> Text -> Params -> RowCodec a -> IO [a] +runQueryStatement prepare conn sql params codec = do + let mk = if prepare then Statement.preparable else Statement.unpreparable + stmt = + mk + (numberPlaceholders sql) + (Encode.buildEncoder params) + (Decode.hasqlRowDecoder codec) + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right rows -> pure rows + Left err -> throwInternal $ "hasql query error: " <> T.pack (show err) + +runExecStatement :: HasqlConn.Connection -> Text -> Params -> IO Int64 +runExecStatement conn sql params = do + let stmt = Encode.buildStatementRowCount sql params + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right n -> pure n + Left err -> throwInternal $ "hasql statement error: " <> T.pack (show err) + +runRawSql :: HasqlConn.Connection -> Text -> IO () +runRawSql conn sql = do + let stmt = Statement.unpreparable sql Encoders.noParams Decoders.noResult + result <- HasqlConn.use conn (Session.statement () stmt) + case result of + Right () -> pure () + Left err -> throwInternal $ "hasql sql error: " <> T.pack (show err) + +beginCommitOrRollback :: HasqlConn.Connection -> WireArbiter registry a -> WireArbiter registry a +beginCommitOrRollback conn action = do + withRunInIO $ \run -> + beginCommitOrRollbackIO conn $ + run $ + local + (\e -> e {activeConn = Just conn, transactionDepth = 1}) + action + +beginCommitOrRollbackIO :: HasqlConn.Connection -> IO a -> IO a +beginCommitOrRollbackIO conn action = mask $ \restore -> do + runRawSql conn "BEGIN" + result <- restore action `onException` rollbackSafely + runRawSql conn "COMMIT" + pure result + where + rollbackSafely = do + _ <- try (runRawSql conn "ROLLBACK") :: IO (Either SomeException ()) + pure () + +beginSavepointTransaction :: HasqlConn.Connection -> WireArbiter registry a -> WireArbiter registry a +beginSavepointTransaction conn action = do + env <- ask + let depth = transactionDepth env + withRunInIO $ \run -> + beginSavepointTransactionIO depth conn $ + run $ + local + (\e -> e {activeConn = Just conn, transactionDepth = depth + 1}) + action + +beginSavepointTransactionIO :: Int -> HasqlConn.Connection -> IO a -> IO a +beginSavepointTransactionIO depth conn action = mask $ \restore -> do + let spName = "arbiter_sp_" <> T.pack (show depth) + runRawSql conn ("SAVEPOINT " <> spName) + result <- + restore action + `onException` do + _ <- try (runRawSql conn ("ROLLBACK TO SAVEPOINT " <> spName)) :: IO (Either SomeException ()) + pure () + runRawSql conn ("RELEASE SAVEPOINT " <> spName) + pure result diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs new file mode 100644 index 00000000000..44fbb6dc6cb --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Interpreter.hs @@ -0,0 +1,180 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem.Interpreter + ( interpretJobSubsystem, + ) +where + +import Arbiter.Core qualified as ArbiterCore +import Arbiter.Core.Codec (Col (CInt8, CText), col, pval) +import Arbiter.Core.Operations qualified as ArbiterOperations +import Data.Id +import Data.Json.Util (UTCTimeMillis) +import Data.Qualified +import Data.Text qualified as Text +import Data.Time +import Hasql.Pool.Extended qualified as HasqlPoolExt +import Imports +import Polysemy +import Polysemy.Input (Input, input) +import Wire.API.Jobs +import Wire.JobSubsystem (JobSubsystem (..), JobSubsystemConfig (..)) +import Wire.JobSubsystem.ArbiterAdapter +import Wire.Postgres (PGConstraints) + +interpretJobSubsystem :: + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + InterpreterFor JobSubsystem r +interpretJobSubsystem conf = + interpret + \case + ScheduleAdminlessSetupJob lusr tid -> scheduleAdminlessSetupJob conf lusr tid + ScheduleAdminlessDeletionJob lusr tid cid scheduledFor -> scheduleAdminlessDeletionJob conf lusr tid cid scheduledFor + ScheduleAdminlessReminderJob lusr tid cid deletionScheduledFor reminderTimeout scheduledFor -> scheduleAdminlessReminderJob conf lusr tid cid deletionScheduledFor reminderTimeout scheduledFor + CancelAdminlessJobsForTeam tid -> cancelAdminlessJobsForTeam conf tid + +scheduleAdminlessSetupJob :: + forall r. + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + Maybe (Local UserId) -> + TeamId -> + Sem r () +scheduleAdminlessSetupJob JobSubsystemConfig {..} lusr teamId = do + requestId <- input @RequestId + pool <- input @HasqlPoolExt.Pool + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + groupKey = "adminless-setup:" <> idToText teamId + arbiterJob = + ( ArbiterCore.defaultGroupedJob + groupKey + (AdminlessSetup (AdminlessSetupJob teamId (tUnqualified <$> lusr) requestId)) + ) + { ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessSetupJobDedupKey teamId, + ArbiterCore.maxAttempts = Just 3 + } + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @ConversationsJobPayload @(WireArbiter JobRegistry) arbiterJob + +scheduleAdminlessDeletionJob :: + forall r. + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + Maybe (Local UserId) -> + TeamId -> + ConvId -> + UTCTime -> + Sem r () +scheduleAdminlessDeletionJob JobSubsystemConfig {..} lusr teamId convId scheduledFor = do + requestId <- input @RequestId + pool <- input + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + groupKey = "adminless-deletion:" <> idToText convId + arbiterJob = + ( ArbiterCore.defaultGroupedJob + groupKey + (AdminlessDeletion (AdminlessDeletionJob teamId convId (tUnqualified <$> lusr) requestId)) + ) + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessJobDedupKey "deletion" convId, + ArbiterCore.maxAttempts = Just 3 + } + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @ConversationsJobPayload @(WireArbiter JobRegistry) arbiterJob + +scheduleAdminlessReminderJob :: + forall r. + (PGConstraints r, Member (Input RequestId) r) => + JobSubsystemConfig -> + Maybe (Local UserId) -> + TeamId -> + ConvId -> + UTCTimeMillis -> + NominalDiffTime -> + UTCTime -> + Sem r () +scheduleAdminlessReminderJob JobSubsystemConfig {..} lusr teamId convId deletionScheduledFor reminderTimeout scheduledFor = do + requestId <- input @RequestId + pool <- input @HasqlPoolExt.Pool + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + groupKey = "adminless-reminder:" <> idToText convId + arbiterJob = + ( ArbiterCore.defaultGroupedJob + groupKey + (AdminlessReminder (AdminlessReminderJob teamId convId (tUnqualified <$> lusr) deletionScheduledFor requestId)) + ) + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.dedupKey = Just . ArbiterCore.IgnoreDuplicate $ adminlessReminderJobDedupKey convId reminderTimeout, + ArbiterCore.maxAttempts = Just 3 + } + embed $ void $ runWireArbiter arbiterEnv $ ArbiterCore.insertJob @ConversationsJobPayload @(WireArbiter JobRegistry) arbiterJob + +cancelAdminlessJobsForTeam :: + forall r. + (PGConstraints r) => + JobSubsystemConfig -> + TeamId -> + Sem r () +cancelAdminlessJobsForTeam JobSubsystemConfig {..} teamId = do + pool <- input @HasqlPoolExt.Pool + let arbiterEnv = mkNewWireArbiterEnv jobSubsystemSchemaName pool + embed $ runWireArbiter arbiterEnv (cancelAdminlessJobsForTeamInTransaction jobSubsystemSchemaName) + where + cancelAdminlessJobsForTeamInTransaction :: Text -> WireArbiter JobRegistry () + cancelAdminlessJobsForTeamInTransaction schemaName = + ArbiterCore.withDbTransaction $ do + jobIds <- + ArbiterCore.executeQuery + ( ArbiterCore.Query + (adminlessJobsForTeamQuery schemaName conversationsQueueName) + [pval CText (idToText teamId)] + (col "id" CInt8) + ) + unless (null jobIds) $ + void $ + ArbiterOperations.cancelJobsBatch schemaName conversationsQueueName jobIds + + adminlessJobsForTeamQuery :: Text -> Text -> Text + adminlessJobsForTeamQuery schemaName tableName = + "SELECT id FROM " + <> quoteIdentifier schemaName + <> "." + <> quoteIdentifier tableName + <> " WHERE claimed_by IS NULL" + <> " AND payload #>> '{data,team_id}' = ?" + <> " AND payload->>'type' IN ('adminless_setup', 'adminless_deletion', 'adminless_reminder')" + <> " FOR UPDATE" + + quoteIdentifier :: Text -> Text + quoteIdentifier identifier = "\"" <> Text.replace "\"" "\"\"" identifier <> "\"" + +adminlessJobDedupKey :: Text -> ConvId -> Text +adminlessJobDedupKey jobType convId = + "adminless-" <> jobType <> ":" <> idToText convId + +adminlessSetupJobDedupKey :: TeamId -> Text +adminlessSetupJobDedupKey teamId = + "adminless-setup:" <> idToText teamId + +adminlessReminderJobDedupKey :: ConvId -> NominalDiffTime -> Text +adminlessReminderJobDedupKey convId reminderTimeout = + adminlessJobDedupKey "reminder" convId <> ":" <> Text.pack (show reminderTimeout) diff --git a/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs new file mode 100644 index 00000000000..c39ac05fc71 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/JobSubsystem/Migrations.hs @@ -0,0 +1,174 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE TypeApplications #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.JobSubsystem.Migrations + ( mkArbiterConnectionString, + runJobMigrations, + ) +where + +import Arbiter.Migrations qualified as ArbiterMigrations +import Control.Exception (bracket, bracket_, throwIO) +import Data.Hashable qualified as Hashable +import Data.Map qualified as Map +import Data.Proxy (Proxy (..)) +import Data.Secret (SecretText, revealSecretText, secretText) +import Data.Text qualified as T +import Data.Text.Encoding qualified as Text +import Hasql.Connection qualified as HasqlConnection +import Hasql.Connection.Settings qualified as HasqlConnectionSettings +import Hasql.Decoders qualified as HasqlDecoders +import Hasql.Encoders qualified as HasqlEncoders +import Hasql.Session qualified as HasqlSession +import Hasql.Statement qualified as HasqlStatement +import Hasql.TH +import Imports +import PostgresqlConnectionString qualified +import System.IO.Error (userError) +import System.Timeout (timeout) +import Util.Options (FilePathSecrets, initCredentials) +import Wire.API.Jobs (JobRegistry, conversationsQueueName) + +-- | Build the secret-bearing connection string used by the Arbiter migration +-- lock and migration runner. +mkArbiterConnectionString :: Map Text Text -> Maybe FilePathSecrets -> IO SecretText +mkArbiterConnectionString pgConfig mFpSecrets = do + mPw <- for mFpSecrets initCredentials + let pgConfig' = maybe pgConfig (\pw -> Map.insert "password" pw pgConfig) mPw + pure . secretText . PostgresqlConnectionString.toKeyValueString $ + PostgresqlConnectionString.fromKeyValueParams pgConfig' + +-- | Apply all migrations for the job registry before constructing any worker +-- pools or accepting jobs. +runJobMigrations :: SecretText -> Text -> IO () +runJobMigrations connStr schemaName = + withArbiterMigrationLock connStr schemaName $ \lockConnection -> do + result <- + ArbiterMigrations.runMigrationsForRegistry + (Proxy @JobRegistry) + (Text.encodeUtf8 $ revealSecretText connStr) + schemaName + ArbiterMigrations.defaultMigrationConfig + case result of + ArbiterMigrations.MigrationSuccess -> ensureAdminlessJobsTeamIndex lockConnection + ArbiterMigrations.MigrationError err -> + throwIO . userError $ + "Arbiter migrations failed for schema " <> T.unpack schemaName <> ": " <> err + where + -- Add the lookup index after Arbiter has created the conversations queue. + -- The partial index only covers unclaimed adminless jobs, which are the rows + -- that feature teardown needs to select and cancel. + ensureAdminlessJobsTeamIndex :: HasqlConnection.Connection -> IO () + ensureAdminlessJobsTeamIndex connection = + runRawSql connection $ + "CREATE INDEX IF NOT EXISTS " + <> quoteIdentifier "conversations_adminless_team_id_idx" + <> " ON " + <> quoteIdentifier schemaName + <> "." + <> quoteIdentifier conversationsQueueName + <> " ((payload #>> '{data,team_id}'))" + <> " WHERE claimed_by IS NULL" + <> " AND payload->>'type' IN ('adminless_setup', 'adminless_deletion', 'adminless_reminder')" + + runRawSql :: HasqlConnection.Connection -> Text -> IO () + runRawSql connection sql = do + result <- + HasqlConnection.use connection $ + HasqlSession.statement + () + (HasqlStatement.unpreparable sql HasqlEncoders.noParams HasqlDecoders.noResult) + either + (\err -> throwIO . userError $ "Arbiter SQL statement failed: " <> show err) + pure + result + + quoteIdentifier :: Text -> Text + quoteIdentifier identifier = "\"" <> T.replace "\"" "\"\"" identifier <> "\"" + +-- | Serialize Arbiter schema migrations across all service instances that can +-- schedule or execute jobs. The lock is held on the same dedicated connection +-- for the whole migration because PostgreSQL advisory locks are session-scoped. +-- The connection is released and closed after the lock is released. +withArbiterMigrationLock :: SecretText -> Text -> (HasqlConnection.Connection -> IO a) -> IO a +withArbiterMigrationLock connStr schemaName action = do + bracket acquireConnection HasqlConnection.release $ \lockConnection -> do + bracket_ + (acquireArbiterMigrationLockWithTimeout lockConnection) + (runAdvisoryLockStatement lockConnection releaseArbiterMigrationLock) + (action lockConnection) + where + lockId :: Int64 + lockId = fromIntegral . Hashable.hash $ ("wire-server:arbiter-migrations:" <> schemaName :: Text) + + acquireArbiterMigrationLockWithTimeout :: HasqlConnection.Connection -> IO () + acquireArbiterMigrationLockWithTimeout connection = do + acquired <- timeout arbiterMigrationLockWaitTimeoutMicros retryUntilAcquired + case acquired of + Just () -> pure () + Nothing -> + throwIO . userError $ + "Timed out waiting for the Arbiter migration lock for schema " <> T.unpack schemaName + where + retryUntilAcquired :: IO () + retryUntilAcquired = do + acquired <- runAdvisoryLockStatement connection tryArbiterMigrationLock + if acquired + then pure () + else do + threadDelay arbiterMigrationLockRetryIntervalMicros + retryUntilAcquired + + arbiterMigrationLockRetryIntervalMicros :: Int + arbiterMigrationLockRetryIntervalMicros = 1_000_000 + + -- Do not let a stuck migration block service startup indefinitely. + arbiterMigrationLockWaitTimeoutMicros :: Int + arbiterMigrationLockWaitTimeoutMicros = 1 * 60 * 1_000_000 + + acquireConnection :: IO HasqlConnection.Connection + acquireConnection = do + connectionResult <- HasqlConnection.acquire . HasqlConnectionSettings.connectionString $ revealSecretText connStr + either + ( \err -> + throwIO . userError $ + "Failed to acquire PostgreSQL connection for Arbiter migration lock: " <> show err + ) + pure + connectionResult + + runAdvisoryLockStatement :: HasqlConnection.Connection -> HasqlStatement.Statement Int64 a -> IO a + runAdvisoryLockStatement connection statement = do + result <- HasqlConnection.use connection (HasqlSession.statement lockId statement) + either + ( \err -> + throwIO . userError $ + "Arbiter migration advisory lock query failed: " <> show err + ) + pure + result + + tryArbiterMigrationLock :: HasqlStatement.Statement Int64 Bool + tryArbiterMigrationLock = + [singletonStatement|SELECT (pg_try_advisory_lock($1 :: bigint) :: bool)|] + + releaseArbiterMigrationLock :: HasqlStatement.Statement Int64 () + releaseArbiterMigrationLock = + [resultlessStatement|SELECT (1 :: integer) FROM (SELECT pg_advisory_unlock($1 :: bigint))|] diff --git a/services/brig/src/Brig/Effects/JwtTools.hs b/libs/wire-subsystems/src/Wire/JwtTools.hs similarity index 81% rename from services/brig/src/Brig/Effects/JwtTools.hs rename to libs/wire-subsystems/src/Wire/JwtTools.hs index 4163490c286..796e01247b9 100644 --- a/services/brig/src/Brig/Effects/JwtTools.hs +++ b/libs/wire-subsystems/src/Wire/JwtTools.hs @@ -17,9 +17,21 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Brig.Effects.JwtTools where +-- | DPoP access-token generation effect. +-- +-- Note: 'CertEnrollmentError' lives here (not in @wire-api@) because its +-- 'RustError' constructor references 'Data.Jwt.Tools.DPoPTokenGenerationError' +-- from the @jwt-tools@ FFI library; moving it to @wire-api@ would pull the +-- @rusty_jwt_tools_ffi@ native library into the pure types package. brig keeps +-- importing it from here unchanged. +module Wire.JwtTools + ( JwtTools (..), + generateDPoPAccessToken, + interpretJwtTools, + CertEnrollmentError (..), + ) +where -import Brig.API.Types (CertEnrollmentError (..)) import Control.Monad.Trans.Except import Data.ByteString.Conversion import Data.Handle (Handle, fromHandle) @@ -39,6 +51,18 @@ import Wire.API.MLS.Epoch (Epoch (..)) import Wire.API.User.Client.DPoPAccessToken (DPoPAccessToken (..), Proof (..)) import Wire.API.User.Profile (Name (..)) +-- | Moved from "Brig.API.Types": kept the exact constructors. @RustError@ +-- wraps the FFI error type from "Data.Jwt.Tools". +data CertEnrollmentError + = NonceNotFound + | RustError Jwt.DPoPTokenGenerationError + | KeyBundleError + | MisconfiguredRequestUrl + | ClientIdSyntaxError + | NotATeamUser + | MissingHandle + | MissingName + data JwtTools m a where GenerateDPoPAccessToken :: -- | A DPoP proof in JWS Compact Serialization format diff --git a/libs/wire-subsystems/src/Wire/LegalHold.hs b/libs/wire-subsystems/src/Wire/LegalHold.hs index bd9c3e61495..f6f7e05b84e 100644 --- a/libs/wire-subsystems/src/Wire/LegalHold.hs +++ b/libs/wire-subsystems/src/Wire/LegalHold.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHold where import Data.Default (def) diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore.hs index 8bd32acf93e..c590927792c 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore where import Data.ByteString.Lazy.Char8 qualified as LC8 diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs index c767c64883b..186e9da8c6c 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra, validateServiceKey) where import Cassandra diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs index 97c072a8903..c294b8ddd2d 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore/Cassandra/Queries.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore.Cassandra.Queries where import Cassandra as C diff --git a/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs b/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs index 17926ebce6c..1c629c723db 100644 --- a/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs +++ b/libs/wire-subsystems/src/Wire/LegalHoldStore/Env.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.LegalHoldStore.Env where import Data.ByteString.Lazy.Char8 qualified as LC8 diff --git a/libs/wire-subsystems/src/Wire/MeetingNotifier.hs b/libs/wire-subsystems/src/Wire/MeetingNotifier.hs new file mode 100644 index 00000000000..ea54de65b7d --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MeetingNotifier.hs @@ -0,0 +1,58 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingNotifier + ( MeetingNotifier (NotifyMeetingMembersAdded, NotifyMeetingEvent), + notifyMeetingMembersAdded, + notifyMeetingEvent, + ) +where + +import Data.Id +import Data.Qualified (Local, Qualified) +import Imports +import Polysemy +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.StoredConversation (LocalMember) + +-- | Interface for all meeting notifications. Routing both the post-commit +-- member-add hook and the create/update/delete lifecycle events through one +-- effect avoids a dependency from the conversation subsystem onto the meetings +-- subsystem. +data MeetingNotifier m a where + -- | Post-commit member-add hook: notify the users added by a successful + -- membership commit to a meeting conversation. Delivered fire-and-forget. + NotifyMeetingMembersAdded :: + Qualified UserId -> + Qualified ConvId -> + Maybe TeamId -> + [UserId] -> + MeetingNotifier m () + -- | Create/update/delete lifecycle event. Delivered synchronously. + NotifyMeetingEvent :: + Local UserId -> + Maybe ConnId -> + [LocalMember] -> + Qualified ConvId -> + Maybe TeamId -> + MeetingEvent.EventType -> + Qualified MeetingId -> + MeetingNotifier m () + +makeSem ''MeetingNotifier diff --git a/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs new file mode 100644 index 00000000000..53ee3bbed38 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MeetingNotifier/Interpreter.hs @@ -0,0 +1,114 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingNotifier.Interpreter + ( interpretMeetingNotifier, + ) +where + +import Data.ByteString.Conversion (toByteString') +import Data.Id +import Data.Qualified (Local, Qualified (..), tDomain, tUnqualified) +import Imports +import Polysemy +import Polysemy.TinyLog (TinyLog) +import Polysemy.TinyLog qualified as TinyLog +import System.Logger qualified as Log +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.MeetingNotifier +import Wire.MeetingsStore qualified as Store +import Wire.MeetingsSubsystem.Notification +import Wire.NotificationSubsystem +import Wire.Sem.Now (Now) +import Wire.Sem.Now qualified as Now +import Wire.StoredConversation (LocalMember) + +-- | Interpret 'MeetingNotifier'. +interpretMeetingNotifier :: + ( Member Store.MeetingsStore r, + Member NotificationSubsystem r, + Member Now r, + Member TinyLog r + ) => + InterpreterFor MeetingNotifier r +interpretMeetingNotifier = interpret $ \case + NotifyMeetingMembersAdded qUser qConvId mTeamId users -> + notifyMeetingMembersAddedImpl qUser qConvId mTeamId users + NotifyMeetingEvent lUser conn members qConvId mTeamId meetingType qMeetingId -> + notifyMeetingEventImpl lUser conn members qConvId mTeamId meetingType qMeetingId + +-- | Deliver @meeting.member-add@ notifications fire-and-forget via 'pushNotificationAsync'. Resolve each alive meeting for the conversation and notify only the users added by the successful membership commit, logging a warning when no alive meeting is found. +notifyMeetingMembersAddedImpl :: + ( Member Store.MeetingsStore r, + Member NotificationSubsystem r, + Member Now r, + Member TinyLog r + ) => + Qualified UserId -> + Qualified ConvId -> + Maybe TeamId -> + [UserId] -> + Sem r () +notifyMeetingMembersAddedImpl qUser qConvId mTeamId users = do + now <- Now.get + meetings <- Store.listMeetingsByConversation (qUnqualified qConvId) now + when (null meetings) $ + TinyLog.warn $ + Log.msg ("alive meeting not found for meeting member-add event" :: ByteString) + . Log.field "conversationId" (toByteString' (qUnqualified qConvId)) + -- `users` are the members added by the commit; the commit creator is already + -- a member and never in `users`, so mkMeetingEventPush (which no longer + -- filters the originator by UserId) does not echo member-add back to them. + -- conn is Nothing: every client connection of each added user should be notified. + for_ meetings $ \meeting -> + pushNotificationAsync $ + mkMeetingEventPush + now + qUser + Nothing + (map userRecipient users) + qConvId + mTeamId + MeetingEvent.MemberAdd + (Qualified meeting.id (qDomain qConvId)) + +-- | Deliver a create/update/delete lifecycle event synchronously via 'pushNotifications'. +notifyMeetingEventImpl :: + ( Member NotificationSubsystem r, + Member Now r + ) => + Local UserId -> + Maybe ConnId -> + [LocalMember] -> + Qualified ConvId -> + Maybe TeamId -> + MeetingEvent.EventType -> + Qualified MeetingId -> + Sem r () +notifyMeetingEventImpl lUser conn members qConvId mTeamId meetingType qMeetingId = do + now <- Now.get + pushNotifications + [ mkMeetingEventPush + now + (Qualified (tUnqualified lUser) (tDomain lUser)) + conn + (map localMemberToRecipient members) + qConvId + mTeamId + meetingType + qMeetingId + ] diff --git a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs b/libs/wire-subsystems/src/Wire/MeetingNotifier/NoOpInterpreter.hs similarity index 62% rename from services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs rename to libs/wire-subsystems/src/Wire/MeetingNotifier/NoOpInterpreter.hs index ec978251ea2..e6b05488253 100644 --- a/services/spar/test/Test/Spar/Sem/ScimExternalIdStoreSpec.hs +++ b/libs/wire-subsystems/src/Wire/MeetingNotifier/NoOpInterpreter.hs @@ -1,8 +1,6 @@ -{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-} - -- This file is part of the Wire Server implementation. -- --- Copyright (C) 2022 Wire Swiss GmbH +-- Copyright (C) 2026 Wire Swiss GmbH -- -- This program is free software: you can redistribute it and/or modify it under -- the terms of the GNU Affero General Public License as published by the Free @@ -17,16 +15,17 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Test.Spar.Sem.ScimExternalIdStoreSpec where +module Wire.MeetingNotifier.NoOpInterpreter + ( discardMeetingNotifier, + ) +where -import Arbitrary () import Imports import Polysemy -import Spar.Sem.ScimExternalIdStore.Mem -import Spar.Sem.ScimExternalIdStore.Spec -import Test.Hspec -import Test.Hspec.QuickCheck +import Wire.MeetingNotifier -spec :: Spec -spec = modifyMaxSuccess (const 1000) $ do - propsForInterpreter "scimExternalIdStoreToMem" snd $ pure . run . scimExternalIdStoreToMem +-- | Interpreter for runtimes which expose no meeting write endpoints. +discardMeetingNotifier :: InterpreterFor MeetingNotifier r +discardMeetingNotifier = interpret $ \case + NotifyMeetingMembersAdded {} -> pure () + NotifyMeetingEvent {} -> pure () diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore.hs b/libs/wire-subsystems/src/Wire/MeetingsStore.hs index 8b6faec2526..51179a1d762 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore.hs @@ -29,7 +29,7 @@ import Data.Vector (Vector) import Data.Vector qualified as V import Imports import Polysemy -import Wire.API.Meeting (Recurrence (..)) +import Wire.API.Meeting (Recurrence (..), TimeZone, parseTimeZone, renderTimeZone) import Wire.API.PostgresMarshall import Wire.API.User.EmailAddress (emailAddressText, fromEmail) import Wire.API.User.Identity (EmailAddress) @@ -43,8 +43,11 @@ data StoredMeeting = StoredMeeting creator :: UserId, -- | start time of the meeting startTime :: UTCTime, - -- | end time of the meeting + -- | end time of the meeting (the indexed effective-end column) endTime :: UTCTime, + -- | IANA time zone identifier of the meeting (NOT NULL; backfilled to + -- 'defaultLegacyTimeZone' and always supplied on create) + tzid :: TimeZone, -- | optional recurrence pattern recurrence :: Maybe Recurrence, -- | conversation where the meeting belongs @@ -62,8 +65,8 @@ data StoredMeeting = StoredMeeting -- | Effective end time of a meeting for expiry and cleanup decisions. -- --- * No recurrence: the meeting's 'endTime'. --- * Bounded recurrence ('until' set): 'max endTime until' -- the meeting is +-- * No recurrence: end_time. +-- * Bounded recurrence ('until' set): @max end_time until@ -- the meeting is -- still alive while its recurrence window is open, even if the original -- time slot has passed. -- * Open-ended recurrence ('until' = 'Nothing'): 'Nothing' -- the meeting @@ -80,6 +83,7 @@ type StoredMeetingTuple = UUID, -- creator UTCTime, -- start_time UTCTime, -- end_time + Text, -- tzid Maybe Text, -- recurrence_frequency Maybe Int32, -- recurrence_interval Maybe UTCTime, -- recurrence_until @@ -98,6 +102,7 @@ instance PostgresMarshall StoredMeetingTuple StoredMeeting where toUUID storedMeeting.creator, storedMeeting.startTime, storedMeeting.endTime, + renderTimeZone storedMeeting.tzid, rFreq, rInterval, rUntil, @@ -115,6 +120,7 @@ instance PostgresUnmarshall StoredMeetingTuple StoredMeeting where creator', startTime', endTime', + tzid', rFreq, rInterval, rUntil, @@ -126,6 +132,7 @@ instance PostgresUnmarshall StoredMeetingTuple StoredMeeting where ) = do rTitle <- first T.pack $ checkedEither title' recurrence' <- postgresUnmarshall (rFreq, rInterval, rUntil) + tzid'' <- maybe (Left "invalid tzid") Right (parseTimeZone tzid') pure StoredMeeting { id = Id id', @@ -133,6 +140,7 @@ instance PostgresUnmarshall StoredMeetingTuple StoredMeeting where creator = Id creator', startTime = startTime', endTime = endTime', + tzid = tzid'', recurrence = recurrence', conversationId = Id conversationId', invitedEmails = mapMaybe emailAddressText (V.toList invitedEmails'), @@ -147,6 +155,7 @@ data MeetingsStore m a where UserId -> UTCTime -> UTCTime -> + TimeZone -> Maybe Recurrence -> ConvId -> [EmailAddress] -> @@ -157,6 +166,7 @@ data MeetingsStore m a where Maybe (Range 1 256 Text) -> Maybe UTCTime -> Maybe UTCTime -> + Maybe TimeZone -> Maybe (Maybe Recurrence) -> MeetingsStore m (Maybe StoredMeeting) DeleteMeeting :: diff --git a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs index 30bfd2fb298..4e191e0f5b9 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsStore/Postgres.hs @@ -25,20 +25,18 @@ module Wire.MeetingsStore.Postgres where import Data.Id +import Data.List qualified as List import Data.Profunctor (dimap) import Data.Range (Range, fromRange) import Data.Time.Clock import Data.UUID (UUID, nil) import Data.Vector qualified as V -import Hasql.Pool import Hasql.Session import Hasql.Statement import Hasql.TH import Imports import Polysemy -import Polysemy.Error (Error, throw) -import Polysemy.Input -import Wire.API.Meeting (Recurrence) +import Wire.API.Meeting (Recurrence, TimeZone, renderTimeZone) import Wire.API.PostgresMarshall (PostgresMarshall (..), PostgresUnmarshall (..), dimapPG) import Wire.API.User.Identity (EmailAddress, fromEmail) import Wire.MeetingsStore @@ -49,10 +47,10 @@ interpretMeetingsStoreToPostgres :: InterpreterFor MeetingsStore r interpretMeetingsStoreToPostgres = interpret $ \case - CreateMeeting title creator startTime endTime recurrence convId emails trial -> - createMeetingImpl title creator startTime endTime recurrence convId emails trial - UpdateMeeting meetingId title startDate endDate schedule -> - updateMeetingImpl meetingId title startDate endDate schedule + CreateMeeting title creator startTime endTime tzid recurrence convId emails trial -> + createMeetingImpl title creator startTime endTime tzid recurrence convId emails trial + UpdateMeeting meetingId title startDate endTime tzid schedule -> + updateMeetingImpl meetingId title startDate endTime tzid schedule DeleteMeeting meetingId -> deleteMeetingImpl meetingId GetMeeting meetingId -> @@ -78,12 +76,13 @@ createMeetingImpl :: UserId -> UTCTime -> UTCTime -> + TimeZone -> Maybe Recurrence -> ConvId -> [EmailAddress] -> Bool -> Sem r StoredMeeting -createMeetingImpl title creator startTime endTime recurrence convId emails trial = do +createMeetingImpl title creator startTime endTime tzid recurrence convId emails trial = do now <- liftIO getCurrentTime let sm = StoredMeeting @@ -92,6 +91,7 @@ createMeetingImpl title creator startTime endTime recurrence convId emails trial creator = creator, startTime = startTime, endTime = endTime, + tzid = tzid, recurrence = recurrence, conversationId = convId, invitedEmails = emails, @@ -108,23 +108,23 @@ insertStatement = (postgresUnmarshall @StoredMeetingTuple @StoredMeeting) [singletonStatement| INSERT INTO meetings - (title, creator, start_time, end_time, + (title, creator, start_time, end_time, tzid, recurrence_frequency, recurrence_interval, recurrence_until, conversation_id, invited_emails, trial, created_at, updated_at) VALUES - ($1 :: text, $2 :: uuid, $3 :: timestamptz, $4 :: timestamptz, - $5 :: text? :: recurrence_frequency, $6 :: int4?, $7 :: timestamptz?, - $8 :: uuid, $9 :: text[], $10 :: boolean, $11 :: timestamptz, $12 :: timestamptz) + ($1 :: text, $2 :: uuid, $3 :: timestamptz, $4 :: timestamptz, $5 :: text, + $6 :: text? :: recurrence_frequency, $7 :: int4?, $8 :: timestamptz?, + $9 :: uuid, $10 :: text[], $11 :: boolean, $12 :: timestamptz, $13 :: timestamptz) RETURNING id :: uuid, title :: text, creator :: uuid, - start_time :: timestamptz, end_time :: timestamptz, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz |] where - tupleWithoutId (_, t, c, st, et, rf, ri, ru, ci, ie, tr, ca, ua) = - (t, c, st, et, rf, ri, ru, ci, ie, tr, ca, ua) + tupleWithoutId (_, t, c, st, et, tz, rf, ri, ru, ci, ie, tr, ca, ua) = + (t, c, st, et, tz, rf, ri, ru, ci, ie, tr, ca, ua) -- * Update @@ -132,6 +132,7 @@ type UpdateStoredMeetingWithRecurrenceTuple = ( Maybe Text, -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe Text, -- tzid Maybe Text, -- recurrence_frequency Maybe Int32, -- recurrence_interval Maybe UTCTime, -- recurrence_until @@ -142,16 +143,18 @@ type UpdateMeetingWithRecurrenceTuple = ( Maybe (Range 1 256 Text), -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe TimeZone, -- tzid Maybe Recurrence, -- recurrence MeetingId -- meeting id ) instance PostgresMarshall UpdateStoredMeetingWithRecurrenceTuple UpdateMeetingWithRecurrenceTuple where - postgresMarshall (mTitle, mStartTime, mEndTime, recurrence, id') = + postgresMarshall (mTitle, mStartTime, mEndTime, mTzid, recurrence, id') = let (rFreq, rInterval, rUntil) = postgresMarshall recurrence in ( fromRange <$> mTitle, mStartTime, mEndTime, + renderTimeZone <$> mTzid, rFreq, rInterval, rUntil, @@ -162,6 +165,7 @@ type UpdateStoredMeetingWithoutRecurrenceTuple = ( Maybe Text, -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe Text, -- tzid UUID -- meeting id ) @@ -169,14 +173,16 @@ type UpdateMeetingWithoutRecurrenceTuple = ( Maybe (Range 1 256 Text), -- title Maybe UTCTime, -- start_time Maybe UTCTime, -- end_time + Maybe TimeZone, -- tzid MeetingId -- meeting id ) instance {-# OVERLAPPING #-} PostgresMarshall UpdateStoredMeetingWithoutRecurrenceTuple UpdateMeetingWithoutRecurrenceTuple where - postgresMarshall (mTitle, mStartTime, mEndTime, id') = + postgresMarshall (mTitle, mStartTime, mEndTime, mTzid, id') = ( fromRange <$> mTitle, mStartTime, mEndTime, + renderTimeZone <$> mTzid, toUUID id' ) @@ -186,14 +192,15 @@ updateMeetingImpl :: Maybe (Range 1 256 Text) -> Maybe UTCTime -> Maybe UTCTime -> + Maybe TimeZone -> Maybe (Maybe Recurrence) -> Sem r (Maybe StoredMeeting) -updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do +updateMeetingImpl meetingId mTitle mStartDate mEndTime mTzid mRecurrence = do case mRecurrence of Nothing -> - runStatement (mTitle, mStartDate, mEndDate, meetingId) updateWithoutRecurrenceStatement + runStatement (mTitle, mStartDate, mEndTime, mTzid, meetingId) updateWithoutRecurrenceStatement Just recurrence -> - runStatement (mTitle, mStartDate, mEndDate, recurrence, meetingId) updateWithRecurrenceStatement + runStatement (mTitle, mStartDate, mEndTime, mTzid, recurrence, meetingId) updateWithRecurrenceStatement where updateWithRecurrenceStatement :: Statement UpdateMeetingWithRecurrenceTuple (Maybe StoredMeeting) updateWithRecurrenceStatement = @@ -207,14 +214,15 @@ updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do SET title = COALESCE($1 :: text?, title), start_time = COALESCE($2 :: timestamptz?, start_time), end_time = COALESCE($3 :: timestamptz?, end_time), - recurrence_frequency = $4 :: text? :: recurrence_frequency, - recurrence_interval = $5 :: int4?, - recurrence_until = $6 :: timestamptz?, + tzid = COALESCE($4 :: text?, tzid), + recurrence_frequency = $5 :: text? :: recurrence_frequency, + recurrence_interval = $6 :: int4?, + recurrence_until = $7 :: timestamptz?, updated_at = NOW() - WHERE id = ($7 :: uuid) + WHERE id = ($8 :: uuid) RETURNING id :: uuid, title :: text, creator :: uuid, - start_time :: timestamptz, end_time :: timestamptz, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz @@ -232,11 +240,12 @@ updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do SET title = COALESCE($1 :: text?, title), start_time = COALESCE($2 :: timestamptz?, start_time), end_time = COALESCE($3 :: timestamptz?, end_time), + tzid = COALESCE($4 :: text?, tzid), updated_at = NOW() - WHERE id = ($4 :: uuid) + WHERE id = ($5 :: uuid) RETURNING id :: uuid, title :: text, creator :: uuid, - start_time :: timestamptz, end_time :: timestamptz, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz @@ -245,10 +254,7 @@ updateMeetingImpl meetingId mTitle mStartDate mEndDate mRecurrence = do -- * Delete deleteMeetingImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => MeetingId -> Sem r () deleteMeetingImpl meetingId = do @@ -277,7 +283,7 @@ getMeetingStatement = [maybeStatement| SELECT id :: uuid, title :: text, creator :: uuid, - start_time :: timestamptz, end_time :: timestamptz, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz @@ -302,7 +308,7 @@ listMeetingsByUserImpl userId cutoffTime = do $ [vectorStatement| SELECT id :: uuid, title :: text, creator :: uuid, - start_time :: timestamptz, end_time :: timestamptz, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz @@ -334,7 +340,7 @@ listMeetingsByConversationImpl convId cutoffTime = do $ [vectorStatement| SELECT id :: uuid, title :: text, creator :: uuid, - start_time :: timestamptz, end_time :: timestamptz, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz @@ -403,36 +409,61 @@ replaceInvitedEmailsImpl meetingId emails = do |] getOldMeetingsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UTCTime -> Int -> Sem r [StoredMeeting] getOldMeetingsImpl cutoffTime batchSize = do - pool <- input - result <- liftIO $ use pool session - either throw pure result + runSession session where + n = fromIntegral batchSize :: Int32 session :: Session [StoredMeeting] - session = statement (cutoffTime, fromIntegral batchSize) $ V.toList <$> listStatement - listStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) - listStatement = + session = do + -- Two separate queries so each branch can use its dedicated partial index: + -- * non-recurring -> idx_meetings_end_time_nonrecurring + -- (end_time) + -- * recurring -> idx_meetings_recurrence_eff_end + -- (GREATEST(end_time, recurrence_until)) + -- A single OR query would match neither partial index and force a scan. + -- Results are merged and re-sorted by 'effectiveEndTime' below. + nonRecurring <- statement (cutoffTime, n) nonRecurringOldStatement + recurring <- statement (cutoffTime, n) recurringOldStatement + pure $ + take batchSize $ + List.sortOn effectiveEndTime (V.toList nonRecurring <> V.toList recurring) + nonRecurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) + nonRecurringOldStatement = refineResult (traverse (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)) $ [vectorStatement| SELECT id :: uuid, title :: text, creator :: uuid, - start_time :: timestamptz, end_time :: timestamptz, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, conversation_id :: uuid, invited_emails :: text[], trial :: boolean, created_at :: timestamptz, updated_at :: timestamptz FROM meetings - WHERE (recurrence_frequency IS NULL AND end_time < ($1 :: timestamptz)) - OR (recurrence_frequency IS NOT NULL AND recurrence_interval IS NOT NULL - AND recurrence_until IS NOT NULL - AND GREATEST(end_time, recurrence_until) < ($1 :: timestamptz)) - ORDER BY end_time ASC - LIMIT ($2 :: int4) - |] + WHERE recurrence_frequency IS NULL + AND end_time < ($1 :: timestamptz) + ORDER BY end_time ASC + LIMIT ($2 :: int4) + |] + recurringOldStatement :: Statement (UTCTime, Int32) (V.Vector StoredMeeting) + recurringOldStatement = + refineResult + (traverse (postgresUnmarshall @StoredMeetingTuple @StoredMeeting)) + $ [vectorStatement| + SELECT + id :: uuid, title :: text, creator :: uuid, + start_time :: timestamptz, end_time :: timestamptz, tzid :: text, + recurrence_frequency :: text?, recurrence_interval :: int4?, recurrence_until :: timestamptz?, + conversation_id :: uuid, invited_emails :: text[], trial :: boolean, + created_at :: timestamptz, updated_at :: timestamptz + FROM meetings + WHERE recurrence_frequency IS NOT NULL + AND recurrence_interval IS NOT NULL + AND recurrence_until IS NOT NULL + AND GREATEST(end_time, recurrence_until) < ($1 :: timestamptz) + ORDER BY GREATEST(end_time, recurrence_until) ASC + LIMIT ($2 :: int4) + |] diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs index b74afeac204..f7731d7cdff 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem.hs @@ -26,18 +26,19 @@ import Imports import Polysemy import Wire.API.Meeting import Wire.API.User.EmailAddress (EmailAddress) -import Wire.StoredConversation (StoredConversation) data MeetingsSubsystem m a where CreateMeeting :: Local UserId -> + ConnId -> NewMeeting -> - MeetingsSubsystem m (Meeting, StoredConversation) + MeetingsSubsystem m MeetingWithConversation UpdateMeeting :: Local UserId -> + ConnId -> Qualified MeetingId -> UpdateMeeting -> - MeetingsSubsystem m (Maybe Meeting) + MeetingsSubsystem m (Maybe MeetingWithConversation) DeleteMeeting :: Local UserId -> ConnId -> @@ -50,6 +51,24 @@ data MeetingsSubsystem m a where ListMeetings :: Local UserId -> MeetingsSubsystem m [Meeting] + CreateMeetingV16 :: + Local UserId -> + ConnId -> + NewMeetingV16 -> + MeetingsSubsystem m MeetingWithConversationV16 + UpdateMeetingV16 :: + Local UserId -> + ConnId -> + Qualified MeetingId -> + UpdateMeetingV16 -> + MeetingsSubsystem m (Maybe MeetingWithConversationV16) + GetMeetingV16 :: + Local UserId -> + Qualified MeetingId -> + MeetingsSubsystem m (Maybe MeetingV16) + ListMeetingsV16 :: + Local UserId -> + MeetingsSubsystem m [MeetingV16] AddInvitedEmails :: Local UserId -> Qualified MeetingId -> diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs index be069e07c04..a5fdc5a57cc 100644 --- a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Interpreter.hs @@ -16,12 +16,14 @@ -- with this program. If not, see . module Wire.MeetingsSubsystem.Interpreter - ( interpretMeetingsSubsystem, - MeetingError (..), + ( MeetingSystemConfig (..), + interpretMeetingsSubsystem, + startTimeTolerance, ) where import Control.Monad.Trans.Maybe (MaybeT (MaybeT, runMaybeT)) +import Data.ByteString.Conversion (toByteString') import Data.Default (def) import Data.Domain (Domain) import Data.Id @@ -34,15 +36,21 @@ import Imports import Polysemy import Polysemy.Error import Polysemy.Input (Input) +import Polysemy.TinyLog (TinyLog) +import Polysemy.TinyLog qualified as TinyLog +import System.Logger qualified as Log import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Role (roleNameWireAdmin) +import Wire.API.Error.Galley (InvalidTimesReason (..), MeetingError (..)) +import Wire.API.Event.Meeting qualified as MeetingEvent import Wire.API.Meeting qualified as API import Wire.API.Routes.MultiTablePaging qualified as MultiTablePaging -import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig, MeetingsPremiumConfig) +import Wire.API.Team.Feature (FeatureStatus (..), LockableFeature (..), MeetingsConfig) import Wire.API.User (BaseProtocolTag (BaseProtocolMLSTag), EmailAddress) import Wire.ConversationSubsystem (ConversationSubsystem) import Wire.ConversationSubsystem qualified as ConversationSubsystem import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem, getFeatureForTeam) +import Wire.MeetingNotifier (MeetingNotifier, notifyMeetingEvent) import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem import Wire.Sem.Now (Now) @@ -51,8 +59,14 @@ import Wire.StoredConversation import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem -data MeetingError = InvalidTimes | EmptyUpdate | MeetingsFeatureDisabled - deriving stock (Eq, Show) +-- | Tolerance applied when validating that a meeting's start time is not in +-- the past. The check always uses the server's clock ('Now.get') as the +-- reference; the client's clock is never trusted. The tolerance only absorbs +-- minor clock skew between client and server and the network/processing delay +-- between the client sending the request and the server observing it (matches +-- the 60s precedent used by SAML2). +startTimeTolerance :: NominalDiffTime +startTimeTolerance = 60 -- | Whether a meeting is still alive at the given cutoff. A meeting is alive -- when its 'Store.effectiveEndTime' is at or after the cutoff, or 'Nothing' @@ -66,42 +80,74 @@ checkMeetingsEnabled :: ) => Maybe TeamId -> Sem r () -checkMeetingsEnabled maybeTeamId = do +checkMeetingsEnabled maybeTeamId = + unlessM (meetingsFeatureEnabled maybeTeamId) $ + throw MeetingsFeatureDisabled + +-- | Like 'checkMeetingsEnabled' but returns the resolved status instead of +-- throwing. Used by read paths (list, get) that treat a disabled feature as +-- "no meetings" rather than as a forbidden operation. +meetingsFeatureEnabled :: + (Member FeaturesConfigSubsystem r) => + Maybe TeamId -> + Sem r Bool +meetingsFeatureEnabled maybeTeamId = case maybeTeamId of - Nothing -> pure () + Nothing -> pure True Just teamId -> do meetingFeature <- getFeatureForTeam @_ @MeetingsConfig teamId - unless (meetingFeature.status == FeatureStatusEnabled) $ - throw MeetingsFeatureDisabled + pure (meetingFeature.status == FeatureStatusEnabled) + +-- | System-wide meeting configuration: the legacy time zone used for V16 +-- meetings, how long meetings stay alive after their effective end time, and +-- how far into the past an update may move a meeting's times. Invariant +-- (checked by callers at startup where configurable): @pastEditPeriod <= +-- validityPeriod@, so an edited meeting stays within the validity window. +data MeetingSystemConfig = MeetingSystemConfig + { legacyTimeZone :: !API.TimeZone, + validityPeriod :: !NominalDiffTime, + pastEditPeriod :: !NominalDiffTime + } interpretMeetingsSubsystem :: ( Member Store.MeetingsStore r, Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member MeetingNotifier r, Member Now r, + Member TinyLog r, Member (Error MeetingError) r, Member (Input (Local ())) r ) => - NominalDiffTime -> + -- | System-wide meeting configuration. + MeetingSystemConfig -> InterpreterFor MeetingsSubsystem r -interpretMeetingsSubsystem validityPeriod = interpret $ \case - CreateMeeting zUser newMeeting -> - createMeetingImpl zUser newMeeting - UpdateMeeting zUser meetingId update -> - updateMeetingImpl zUser meetingId update validityPeriod +interpretMeetingsSubsystem cfg = interpret $ \case + CreateMeeting zUser connId newMeeting -> + createMeetingImpl zUser connId newMeeting + UpdateMeeting zUser connId meetingId update -> + updateMeetingImpl zUser connId meetingId update cfg.validityPeriod cfg.pastEditPeriod DeleteMeeting zUser connId meetingId -> - deleteMeetingImpl zUser connId meetingId validityPeriod + deleteMeetingImpl zUser connId meetingId cfg.validityPeriod GetMeeting zUser meetingId -> - getMeetingImpl zUser meetingId validityPeriod + getMeetingImpl zUser meetingId cfg.validityPeriod ListMeetings zUser -> - listMeetingsImpl zUser validityPeriod + listMeetingsImpl zUser cfg.validityPeriod + CreateMeetingV16 zUser connId newMeeting -> + API.toLegacyWithConv <$> createMeetingImpl zUser connId (API.fromLegacyNewMeeting cfg.legacyTimeZone newMeeting) + UpdateMeetingV16 zUser connId meetingId update -> + updateMeetingV16Impl zUser connId meetingId update cfg.validityPeriod cfg.pastEditPeriod + GetMeetingV16 zUser meetingId -> + fmap API.toLegacy <$> getMeetingImpl zUser meetingId cfg.validityPeriod + ListMeetingsV16 zUser -> + map API.toLegacy <$> listMeetingsImpl zUser cfg.validityPeriod AddInvitedEmails zUser meetingId emails -> - addInvitedEmailsImpl zUser meetingId emails validityPeriod + addInvitedEmailsImpl zUser meetingId emails cfg.validityPeriod RemoveInvitedEmails zUser meetingId emails -> - removeInvitedEmailsImpl zUser meetingId emails validityPeriod + removeInvitedEmailsImpl zUser meetingId emails cfg.validityPeriod ReplaceInvitedEmails zUser meetingId emails -> - replaceInvitedEmailsImpl zUser meetingId emails validityPeriod + replaceInvitedEmailsImpl zUser meetingId emails cfg.validityPeriod CleanupOldMeetings cutoffTime batchSize -> cleanupOldMeetingsImpl cutoffTime batchSize @@ -110,25 +156,31 @@ createMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member MeetingNotifier r, + Member Now r, Member (Error MeetingError) r ) => Local UserId -> + ConnId -> API.NewMeeting -> - Sem r (API.Meeting, StoredConversation) -createMeetingImpl zUser newMeeting = do + Sem r API.MeetingWithConversation +createMeetingImpl zUser connId newMeeting = do -- Look up user's team once and reuse for both checks conversationTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) checkMeetingsEnabled conversationTeamId - -- Validate that endTime > startTime + -- Validate that the meeting ends after it starts (end_time is the source of + -- truth; positivity was previously checked on the derived duration). when (newMeeting.endTime <= newMeeting.startTime) $ - throw InvalidTimes + throw (InvalidTimes EndBeforeStart) + -- Validate that startTime is not in the past (within tolerance) + now <- Now.get + when (newMeeting.startTime < addUTCTime (negate startTimeTolerance) now) $ + throw (InvalidTimes StartTimeTooFarInPast) - -- Determine trial status based on team membership and premium feature - trial <- case conversationTeamId of - Nothing -> pure True -- Personal users create trial meetings - Just teamId -> do - premiumFeature <- getFeatureForTeam @_ @MeetingsPremiumConfig teamId - pure $ premiumFeature.status /= FeatureStatusEnabled + -- Determine trial status: personal users (no team) create trial meetings. + -- The deprecated meetingsPremium feature flag no longer affects this; team + -- meetings are always non-trial (see WPB-26771). + let trial = isNothing conversationTeamId -- Create conversation with the meeting creator as the only member (admin role) let newConv = @@ -138,7 +190,8 @@ createMeetingImpl zUser newMeeting = do newConvName = Just newMeeting.title, -- InviteAccess is required so MLS commits can add participants via -- performConversationJoin (ensureAccess conv InviteAccess). - newConvAccess = Set.fromList [PrivateAccess, InviteAccess], + -- CodeAccess allows joining the meeting conversation by code. + newConvAccess = Set.fromList [InviteAccess, CodeAccess], newConvAccessRoles = Nothing, newConvTeam = ConvTeamInfo <$> conversationTeamId, newConvMessageTimer = Nothing, @@ -163,33 +216,38 @@ createMeetingImpl zUser newMeeting = do (tUnqualified zUser) newMeeting.startTime newMeeting.endTime + newMeeting.tzid newMeeting.recurrence storedConv.id_ newMeeting.invitedEmails trial - -- Return created meeting - pure - ( storedMeetingToMeeting (tDomain zUser) storedMeeting, - storedConv - ) + let qMeetingId = Qualified storedMeeting.id (tDomain zUser) + notifyMeetingEvent zUser (Just connId) storedConv.localMembers (Qualified storedConv.id_ (tDomain zUser)) conversationTeamId MeetingEvent.Create qMeetingId + + pure $ storedMeetingToMeetingWithConversation zUser storedConv storedMeeting updateMeetingImpl :: ( Member Store.MeetingsStore r, + Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member MeetingNotifier r, + Member TinyLog r, Member (Error MeetingError) r, Member Now r ) => Local UserId -> + ConnId -> Qualified MeetingId -> API.UpdateMeeting -> NominalDiffTime -> - Sem r (Maybe API.Meeting) -updateMeetingImpl zUser meetingId update validityPeriod = do + NominalDiffTime -> + Sem r (Maybe API.MeetingWithConversation) +updateMeetingImpl zUser connId meetingId update validityPeriod pastEditPeriod = do maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) checkMeetingsEnabled maybeTeamId - when (isNothing update.title && isNothing update.startTime && isNothing update.endTime && isNothing update.recurrence) $ + when (isNothing update.title && isNothing update.startTime && isNothing update.endTime && isNothing update.recurrence && isNothing update.tzid) $ throw EmptyUpdate runMaybeT $ do @@ -198,11 +256,21 @@ updateMeetingImpl zUser meetingId update validityPeriod = do let cutoff = addUTCTime (negate validityPeriod) now guard $ isAlive cutoff meeting guard $ qDomain meetingId == tDomain zUser + guard $ meeting.creator == tUnqualified zUser + -- Creation enforces endTime > startTime (createMeetingImpl); re-establish + -- the invariant on the *effective* times after this update, since either + -- bound may change independently. when (fromMaybe meeting.startTime update.startTime >= fromMaybe meeting.endTime update.endTime) $ lift $ - throw InvalidTimes + throw (InvalidTimes EndBeforeStart) + -- New time values may be moved into the past so that past/ongoing + -- meetings can be corrected to what actually happened, but no further + -- than `pastEditPeriod` (WPB-28080). Only provided values are checked; + -- unchanged stored times are not re-validated. + let pastEditCutoff = addUTCTime (negate pastEditPeriod) now + for_ update.startTime $ \t -> when (t < pastEditCutoff) $ lift $ throw (InvalidTimes TimesBeyondPastEditWindow) + for_ update.endTime $ \t -> when (t < pastEditCutoff) $ lift $ throw (InvalidTimes TimesBeyondPastEditWindow) - guard $ meeting.creator == tUnqualified zUser updatedMeeting <- MaybeT $ Store.updateMeeting @@ -210,14 +278,44 @@ updateMeetingImpl zUser meetingId update validityPeriod = do update.title update.startTime update.endTime + update.tzid update.recurrence - pure $ storedMeetingToMeeting (tDomain zUser) updatedMeeting + conv <- MaybeT $ getMeetingConversationOrFail meetingId updatedMeeting.conversationId + lift $ notifyMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Update meetingId + pure $ storedMeetingToMeetingWithConversation zUser conv updatedMeeting + +-- | V16 update path: 'API.UpdateMeetingV16' is 'API.UpdateMeeting' (both +-- carry an optional @end_time@), so this delegates straight through to the +-- shared update implementation and re-shapes the result. The legacy request +-- shape carries no @tzid@ from V15\/V16 clients, and an omitted @tzid@ leaves +-- the stored time zone unchanged, so no legacy time zone injection is needed. +updateMeetingV16Impl :: + ( Member Store.MeetingsStore r, + Member ConversationSubsystem r, + Member TeamSubsystem r, + Member FeaturesConfigSubsystem r, + Member MeetingNotifier r, + Member TinyLog r, + Member (Error MeetingError) r, + Member Now r + ) => + Local UserId -> + ConnId -> + Qualified MeetingId -> + API.UpdateMeetingV16 -> + NominalDiffTime -> + NominalDiffTime -> + Sem r (Maybe API.MeetingWithConversationV16) +updateMeetingV16Impl zUser connId meetingId updateL validityPeriod pastEditPeriod = + fmap API.toLegacyWithConv <$> updateMeetingImpl zUser connId meetingId updateL validityPeriod pastEditPeriod deleteMeetingImpl :: ( Member Store.MeetingsStore r, Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, + Member MeetingNotifier r, + Member TinyLog r, Member (Error MeetingError) r, Member Now r ) => @@ -239,12 +337,13 @@ deleteMeetingImpl zUser connId meetingId validityPeriod = do guard $ meeting.creator == tUnqualified zUser let convId = meeting.conversationId lConvId = qualifyAs zUser convId - conv <- MaybeT $ ConversationSubsystem.internalGetConversation convId + conv <- MaybeT $ getMeetingConversationOrFail meetingId convId when (conv.metadata.cnvmGroupConvType == Just MeetingConversation) $ lift $ void $ ConversationSubsystem.deleteLocalConversation zUser connId lConvId lift $ Store.deleteMeeting (qUnqualified meetingId) + lift $ notifyMeetingEvent zUser (Just connId) conv.localMembers (Qualified conv.id_ (tDomain zUser)) maybeTeamId MeetingEvent.Delete meetingId pure $ isJust result getMeetingImpl :: @@ -252,7 +351,6 @@ getMeetingImpl :: Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member (Error MeetingError) r, Member Now r ) => Local UserId -> @@ -261,23 +359,46 @@ getMeetingImpl :: Sem r (Maybe API.Meeting) getMeetingImpl zUser meetingId validityPeriod = do maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) - checkMeetingsEnabled maybeTeamId - -- Get meeting from store - runMaybeT $ do - storedMeeting <- MaybeT $ Store.getMeeting (qUnqualified meetingId) - now <- lift Now.get - let cutoff = addUTCTime (negate validityPeriod) now - guard $ isAlive cutoff storedMeeting - guard $ qDomain meetingId == tDomain zUser - -- Check authorization: user must be creator OR member of the associated conversation - let isCreator = storedMeeting.creator == tUnqualified zUser - if isCreator - then pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting - else do - -- Check if user is a member of the conversation - let convId = storedMeeting.conversationId - void $ MaybeT $ ConversationSubsystem.internalGetLocalMember convId (tUnqualified zUser) - pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting -- User is a member, authorized + enabled <- meetingsFeatureEnabled maybeTeamId + if enabled + then runMaybeT $ do + storedMeeting <- MaybeT $ Store.getMeeting (qUnqualified meetingId) + now <- lift Now.get + let cutoff = addUTCTime (negate validityPeriod) now + guard $ isAlive cutoff storedMeeting + guard $ qDomain meetingId == tDomain zUser + -- Check authorization: user must be creator OR member of the associated conversation + let isCreator = storedMeeting.creator == tUnqualified zUser + if isCreator + then pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting + else do + -- Check if user is a member of the conversation + let convId = storedMeeting.conversationId + void $ MaybeT $ ConversationSubsystem.internalGetLocalMember convId (tUnqualified zUser) + pure $ storedMeetingToMeeting (tDomain zUser) storedMeeting -- User is a member, authorized + else pure Nothing + +-- | Look up the 'StoredConversation' associated with a meeting. When the +-- conversation cannot be found (a data-integrity anomaly), a warning is logged +-- before failing: otherwise the missing conversation is indistinguishable from +-- a missing meeting for callers. +getMeetingConversationOrFail :: + ( Member ConversationSubsystem r, + Member TinyLog r + ) => + Qualified MeetingId -> + ConvId -> + Sem r (Maybe StoredConversation) +getMeetingConversationOrFail meetingId convId = do + mConv <- ConversationSubsystem.internalGetConversation convId + case mConv of + Just conv -> pure (Just conv) + Nothing -> do + TinyLog.warn $ + Log.msg ("conversation not found for meeting" :: ByteString) + . Log.field "conversationId" (toByteString' convId) + . Log.field "meetingId" (toByteString' (qUnqualified meetingId)) + pure Nothing -- Helper function to convert StoredMeeting to API.Meeting storedMeetingToMeeting :: Domain -> Store.StoredMeeting -> API.Meeting @@ -288,20 +409,37 @@ storedMeetingToMeeting domain sm = API.creator = Qualified sm.creator domain, API.startTime = sm.startTime, API.endTime = sm.endTime, + API.tzid = sm.tzid, API.recurrence = sm.recurrence, API.conversationId = Qualified sm.conversationId domain, API.invitedEmails = sm.invitedEmails, - API.trial = sm.trial, API.createdAt = sm.createdAt, API.updatedAt = sm.updatedAt } +-- | Like 'storedMeetingToMeeting', but additionally carries the full +-- 'API.Conversation' associated with the meeting. +-- +-- The local user's domain ('tDomain lUser') is used to qualify the meeting, +-- its creator and its conversation: meetings are not federated, and every +-- meeting operation guards @qDomain meetingId == tDomain zUser@. The +-- conversation itself is always created locally. +storedMeetingToMeetingWithConversation :: + Local UserId -> + StoredConversation -> + Store.StoredMeeting -> + API.MeetingWithConversation +storedMeetingToMeetingWithConversation lUser conv sm = + API.MeetingWithConversation + { API.meeting = storedMeetingToMeeting (tDomain lUser) sm, + API.conversation = conversationView lUser (Just lUser) conv + } + listMeetingsImpl :: ( Member Store.MeetingsStore r, Member ConversationSubsystem r, Member TeamSubsystem r, Member FeaturesConfigSubsystem r, - Member (Error MeetingError) r, Member Now r ) => Local UserId -> @@ -309,17 +447,20 @@ listMeetingsImpl :: Sem r [API.Meeting] listMeetingsImpl zUser validityPeriod = do maybeTeamId <- TeamSubsystem.internalGetOneUserTeam (tUnqualified zUser) - checkMeetingsEnabled maybeTeamId - now <- Now.get - let cutoff = addUTCTime (negate validityPeriod) now - -- List all meetings created by the user - createdMeetings <- Store.listMeetingsByUser (tUnqualified zUser) cutoff - -- Loop over local conversations accessible by the user, then filter to only keep meetings. - memberMeetings <- getAllMemberMeetings zUser cutoff - -- Combine and deduplicate - let allMeetings = map (storedMeetingToMeeting (tDomain zUser)) createdMeetings <> memberMeetings - uniqueMeetings = Map.elems $ Map.fromList [(m.id, m) | m <- allMeetings] - pure uniqueMeetings + enabled <- meetingsFeatureEnabled maybeTeamId + if enabled + then do + now <- Now.get + let cutoff = addUTCTime (negate validityPeriod) now + -- List all meetings created by the user + createdMeetings <- Store.listMeetingsByUser (tUnqualified zUser) cutoff + -- Loop over local conversations accessible by the user, then filter to only keep meetings. + memberMeetings <- getAllMemberMeetings zUser cutoff + -- Combine and deduplicate + let allMeetings = map (storedMeetingToMeeting (tDomain zUser)) createdMeetings <> memberMeetings + uniqueMeetings = Map.elems $ Map.fromList [(m.id, m) | m <- allMeetings] + pure uniqueMeetings + else pure [] getAllMemberMeetings :: ( Member Store.MeetingsStore r, diff --git a/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs new file mode 100644 index 00000000000..7eb3ad27faa --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MeetingsSubsystem/Notification.hs @@ -0,0 +1,60 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingsSubsystem.Notification + ( mkMeetingEventPush, + ) +where + +import Data.Default (def) +import Data.Id +import Data.Json.Util (toJSONObject) +import Data.Qualified (Qualified (..)) +import Data.Time.Clock (UTCTime) +import Imports +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.API.Push.V2 qualified as PushV2 +import Wire.NotificationSubsystem + +-- | Build the common push event structure used by all meeting lifecycle events. +mkMeetingEventPush :: + UTCTime -> + Qualified UserId -> + Maybe ConnId -> + [Recipient] -> + Qualified ConvId -> + Maybe TeamId -> + MeetingEvent.EventType -> + Qualified MeetingId -> + Push +mkMeetingEventPush now qUser conn recipients qConvId mTeamId meetingType qMeetingId = + def + { origin = Just (qUnqualified qUser), + json = + toJSONObject + MeetingEvent.Event + { evtType = meetingType, + evtMeeting = qMeetingId, + evtConv = qConvId, + evtFrom = MeetingEvent.EventFromUser qUser, + evtTime = now, + evtTeam = mTeamId + }, + recipients = recipients, + route = PushV2.RouteDirect, + conn + } diff --git a/libs/wire-subsystems/src/Wire/MigrationLock.hs b/libs/wire-subsystems/src/Wire/MigrationLock.hs index 0c9eedf1260..8c97876170f 100644 --- a/libs/wire-subsystems/src/Wire/MigrationLock.hs +++ b/libs/wire-subsystems/src/Wire/MigrationLock.hs @@ -17,6 +17,23 @@ {-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE TypeApplications #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.MigrationLock where import Data.Bits @@ -25,6 +42,7 @@ import Data.Id import Data.UUID qualified as UUID import Data.Vector (Vector) import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt import Hasql.Session qualified as Session import Hasql.Statement qualified as Hasql import Hasql.TH @@ -97,7 +115,7 @@ withMigrationLocks lockType maxWait lockables action = do lockAcquired <- embed newEmptyMVar actionCompleted <- embed newEmptyMVar - pool <- input + pool <- (.rawPool) <$> input @HasqlPoolExt.Pool lockThread <- async . embed . Hasql.use pool $ do let lockIds = fmap lockKey lockables Session.statement lockIds acquireLocks diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs new file mode 100644 index 00000000000..644c5f6406f --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageStore where + +import Data.Id +import Data.Map qualified as Map +import Polysemy +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage + +data MlsKeyPackageStore m a where + InsertKeyPackages :: UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> MlsKeyPackageStore m () + LookupKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageStore m [(KeyPackageRef, KeyPackageData)] + LookupKeyPackagesBulk :: [(UserId, ClientId, CipherSuiteTag)] -> MlsKeyPackageStore m (Map.Map (UserId, ClientId, CipherSuiteTag) [(KeyPackageRef, KeyPackageData)]) + DeleteKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> MlsKeyPackageStore m () + DeleteAllKeyPackages :: UserId -> ClientId -> [CipherSuiteTag] -> MlsKeyPackageStore m () + DeleteKeyPackage :: UserId -> ClientId -> CipherSuiteTag -> KeyPackageRef -> MlsKeyPackageStore m () + +makeSem ''MlsKeyPackageStore diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs new file mode 100644 index 00000000000..2ec1aa2f73b --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageStore/Cassandra.hs @@ -0,0 +1,91 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageStore.Cassandra (interpretMlsKeyPackageStoreToCassandra) where + +import Cassandra as C hiding (Client) +import Data.Id +import Data.List.Extra (chunksOf) +import Data.Map qualified as Map +import Data.Set qualified as Set +import Imports +import Polysemy +import Polysemy.Embed +import UnliftIO.Async (pooledForConcurrentlyN_, pooledMapConcurrentlyN) +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage (KeyPackageData, KeyPackageRef) +import Wire.MlsKeyPackageStore (MlsKeyPackageStore (..)) + +interpretMlsKeyPackageStoreToCassandra :: (Member (Embed IO) r) => ClientState -> InterpreterFor MlsKeyPackageStore r +interpretMlsKeyPackageStoreToCassandra cas = + interpret $ + runEmbedded (runClient cas) . \case + InsertKeyPackages u c ps -> embed $ insertKeyPackages u c ps + LookupKeyPackages u c s -> embed $ lookupKeyPackages u c s + LookupKeyPackagesBulk requests -> embed $ lookupKeyPackagesBulk requests + DeleteKeyPackages u c s rs -> embed $ deleteKeyPackages u c s rs + DeleteAllKeyPackages u c ss -> embed $ deleteAllKeyPackages u c ss + DeleteKeyPackage u c s r -> embed $ deleteKeyPackage u c s r + +insertKeyPackages :: (MonadClient m) => UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> m () +insertKeyPackages u c ps = retry x5 . batch $ do + setType BatchLogged + setConsistency LocalQuorum + for_ ps $ \(r, s, p) -> addPrepQuery insertQuery (u, c, s, p, r) + +lookupKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> m [(KeyPackageRef, KeyPackageData)] +lookupKeyPackages u c s = retry x1 $ query lookupQuery (params LocalQuorum (u, c, s)) + +lookupKeyPackagesBulk :: (MonadClient m, MonadUnliftIO m) => [(UserId, ClientId, CipherSuiteTag)] -> m (Map (UserId, ClientId, CipherSuiteTag) [(KeyPackageRef, KeyPackageData)]) +lookupKeyPackagesBulk requests = + Map.fromListWith (<>) . concat + <$> pooledMapConcurrentlyN 16 lookupClient (Map.toList grouped) + where + grouped = Map.fromListWith Set.union [((u, c), Set.singleton s) | (u, c, s) <- requests] + + lookupClient ((u, c), suites) = + fmap concat . for (chunksOf 8 (Set.toList suites)) $ \suiteChunk -> do + rows <- retry x1 $ query lookupBulkQuery (params LocalQuorum (u, c, suiteChunk)) + pure [((u, c, suite), [(ref, packageData)]) | (suite, ref, packageData) <- rows] + +deleteKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> m () +deleteKeyPackages u c s rs = retry x5 $ write deleteQuery (params LocalQuorum (u, c, s, rs)) + +deleteAllKeyPackages :: (MonadClient m, MonadUnliftIO m, Foldable f) => UserId -> ClientId -> f CipherSuiteTag -> m () +deleteAllKeyPackages u c ss = pooledForConcurrentlyN_ 16 ss $ \s -> retry x5 $ write deleteAllQuery (params LocalQuorum (u, c, s)) + +deleteKeyPackage :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> KeyPackageRef -> m () +deleteKeyPackage u c s r = do + retry x5 $ write deleteKeyPackageQuery (params LocalQuorum (u, c, s, r)) + +insertQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageData, KeyPackageRef) () +insertQuery = "INSERT INTO mls_key_packages (user, client, cipher_suite, data, ref) VALUES (?, ?, ?, ?, ?)" + +lookupQuery :: PrepQuery R (UserId, ClientId, CipherSuiteTag) (KeyPackageRef, KeyPackageData) +lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" + +lookupBulkQuery :: PrepQuery R (UserId, ClientId, [CipherSuiteTag]) (CipherSuiteTag, KeyPackageRef, KeyPackageData) +lookupBulkQuery = "SELECT cipher_suite, ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite IN ?" + +deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, [KeyPackageRef]) () +deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref IN ?" + +deleteAllQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag) () +deleteAllQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" + +deleteKeyPackageQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageRef) () +deleteKeyPackageQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref = ?" diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs new file mode 100644 index 00000000000..1f18b53d372 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem.hs @@ -0,0 +1,46 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageSubsystem where + +import Data.Id +import Data.Time.Clock (NominalDiffTime) +import Data.Time.Clock.POSIX (POSIXTime) +import Imports +import Polysemy +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage +import Wire.API.MLS.Lifetime + +data MlsKeyPackageSubsystem m a where + InsertMlsKeyPackages :: UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> MlsKeyPackageSubsystem m () + ClaimMlsKeyPackage :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m (Maybe (KeyPackageRef, KeyPackageData)) + HasMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m Bool + HasMlsKeyPackagesBulk :: [(UserId, ClientId, CipherSuiteTag)] -> MlsKeyPackageSubsystem m (Set (UserId, ClientId, CipherSuiteTag)) + CountMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> MlsKeyPackageSubsystem m Int64 + DeleteMlsKeyPackages :: UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> MlsKeyPackageSubsystem m () + DeleteAllMlsKeyPackages :: UserId -> ClientId -> [CipherSuiteTag] -> MlsKeyPackageSubsystem m () + +makeSem ''MlsKeyPackageSubsystem + +validateKeyPackageLifetime :: POSIXTime -> Maybe NominalDiffTime -> Lifetime -> Either Text () +validateKeyPackageLifetime now maxLifetime lifetime = do + when (tsPOSIX lifetime.ltNotBefore > now) $ Left "Key package not_before date is in the future" + when (tsPOSIX lifetime.ltNotAfter <= now) $ Left "Key package is expired" + for_ maxLifetime $ \maxAge -> when (tsPOSIX lifetime.ltNotAfter > now + maxAge) $ Left "Key package expiration time is too far in the future" diff --git a/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs new file mode 100644 index 00000000000..4e9ca717600 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/MlsKeyPackageSubsystem/Interpreter.hs @@ -0,0 +1,150 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MlsKeyPackageSubsystem.Interpreter (interpretMlsKeyPackageSubsystem) where + +import Control.Concurrent qualified as C +import Control.Error (atMay) +import Control.Monad.Random (randomRIO) +import Data.Id +import Data.Map qualified as Map +import Data.Set qualified as Set +import Data.Time.Clock (NominalDiffTime) +import Data.Time.Clock.POSIX +import Imports +import Polysemy +import Polysemy.Resource (Resource, bracket) +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode +import Wire.API.MLS.Serialisation +import Wire.MlsKeyPackageStore qualified as Store +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem (..), validateKeyPackageLifetime) + +interpretMlsKeyPackageSubsystem :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r, Member Resource r) => Maybe NominalDiffTime -> C.MVar () -> InterpreterFor MlsKeyPackageSubsystem r +interpretMlsKeyPackageSubsystem configuredLifetime lock = interpret $ \case + InsertMlsKeyPackages u c ps -> insertMlsKeyPackages u c ps + ClaimMlsKeyPackage u c s -> claimMlsKeyPackage configuredLifetime lock u c s + HasMlsKeyPackages u c s -> hasMlsKeyPackages configuredLifetime u c s + HasMlsKeyPackagesBulk requests -> hasMlsKeyPackagesBulk configuredLifetime requests + CountMlsKeyPackages u c s -> countMlsKeyPackages configuredLifetime u c s + DeleteMlsKeyPackages u c s rs -> deleteMlsKeyPackages u c s rs + DeleteAllMlsKeyPackages u c ss -> deleteAllMlsKeyPackages u c ss + +insertMlsKeyPackages :: (Member Store.MlsKeyPackageStore r) => UserId -> ClientId -> [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> Sem r () +insertMlsKeyPackages u c ps = Store.insertKeyPackages u c ps + +hasMlsKeyPackages :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Maybe NominalDiffTime -> UserId -> ClientId -> CipherSuiteTag -> Sem r Bool +hasMlsKeyPackages maxLifetime u c s = do + rows <- Store.lookupKeyPackages u c s + now <- embed getPOSIXTime + pure . any (isUsable now maxLifetime) $ mapMaybe decode rows + where + decode :: (KeyPackageRef, KeyPackageData) -> Maybe KeyPackage + decode (_, packageData) = do + package <- either (const Nothing) Just (decodeMLS' (kpData packageData) :: Either Text (RawMLS KeyPackage)) + pure package.value + + isUsable now configuredLifetime package = + case package.leafNode.source of + LeafNodeSourceKeyPackage lifetime -> isRight (validateKeyPackageLifetime now configuredLifetime lifetime) + _ -> False + +hasMlsKeyPackagesBulk :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Maybe NominalDiffTime -> [(UserId, ClientId, CipherSuiteTag)] -> Sem r (Set (UserId, ClientId, CipherSuiteTag)) +hasMlsKeyPackagesBulk maxLifetime requests = do + rows <- Store.lookupKeyPackagesBulk requests + now <- embed getPOSIXTime + pure . Set.fromList . map fst . filter (any (isUsable now maxLifetime) . mapMaybe decode . snd) $ Map.toList rows + where + decode :: (KeyPackageRef, KeyPackageData) -> Maybe KeyPackage + decode (_, packageData) = do + package <- either (const Nothing) Just (decodeMLS' (kpData packageData) :: Either Text (RawMLS KeyPackage)) + pure package.value + + isUsable now configuredLifetime package = + case package.leafNode.source of + LeafNodeSourceKeyPackage lifetime -> isRight (validateKeyPackageLifetime now configuredLifetime lifetime) + _ -> False + +claimMlsKeyPackage :: + (Member Store.MlsKeyPackageStore r, Member (Embed IO) r, Member Resource r) => + Maybe NominalDiffTime -> + C.MVar () -> + UserId -> + ClientId -> + CipherSuiteTag -> + Sem r (Maybe (KeyPackageRef, KeyPackageData)) +claimMlsKeyPackage maxLifetime lock u c s = + bracket (embed $ C.takeMVar lock) (embed . C.putMVar lock) (const claim) + where + claim :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Sem r (Maybe (KeyPackageRef, KeyPackageData)) + claim = do + candidates <- getNonClaimedKeyPackages maxLifetime u c s + case candidates of + [] -> pure Nothing + _ -> do + mk <- embed (pick candidates) + for mk $ \candidate -> do + Store.deleteKeyPackage u c s (fst candidate) + pure candidate + pick :: [a] -> IO (Maybe a) + pick [] = pure Nothing + pick xs = do + i <- randomRIO (0, length xs - 1) + pure (atMay xs i) + +countMlsKeyPackages :: + ( Member Store.MlsKeyPackageStore r, + Member (Embed IO) r + ) => + Maybe NominalDiffTime -> + UserId -> + ClientId -> + CipherSuiteTag -> + Sem r Int64 +countMlsKeyPackages configuredLifetime u c s = fromIntegral . length <$> getNonClaimedKeyPackages configuredLifetime u c s + +deleteMlsKeyPackages :: + (Member Store.MlsKeyPackageStore r) => + UserId -> + ClientId -> + CipherSuiteTag -> + [KeyPackageRef] -> + Sem r () +deleteMlsKeyPackages u c s rs = Store.deleteKeyPackages u c s rs + +deleteAllMlsKeyPackages :: (Member Store.MlsKeyPackageStore r) => UserId -> ClientId -> [CipherSuiteTag] -> Sem r () +deleteAllMlsKeyPackages u c ss = Store.deleteAllKeyPackages u c ss + +-- | Fetch all unclaimed non-expired key packages for a given client and delete +-- from the database those that have expired. +getNonClaimedKeyPackages :: (Member Store.MlsKeyPackageStore r, Member (Embed IO) r) => Maybe NominalDiffTime -> UserId -> ClientId -> CipherSuiteTag -> Sem r [(KeyPackageRef, KeyPackageData)] +getNonClaimedKeyPackages maxLifetime u c s = do + rows <- Store.lookupKeyPackages u c s + now <- embed getPOSIXTime + let decoded = mapMaybe decode rows + (expired, usable) = partition (isExpired now maxLifetime) decoded + Store.deleteKeyPackages u c s (map (fst . snd) expired) + pure (map snd usable) + where + decode :: (KeyPackageRef, KeyPackageData) -> Maybe (KeyPackage, (KeyPackageRef, KeyPackageData)) + decode row@(_, packageData) = do + package <- either (const Nothing) Just (decodeMLS' (kpData packageData) :: Either Text (RawMLS KeyPackage)) + pure (package.value, row) + isExpired now configuredLifetime (package, _) = case package.leafNode.source of + LeafNodeSourceKeyPackage lifetime -> isLeft (validateKeyPackageLifetime now configuredLifetime lifetime) + _ -> True diff --git a/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs index adb170507d4..ca42455ee0f 100644 --- a/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/NotificationSubsystem/Interpreter.hs @@ -91,7 +91,19 @@ pushAsyncImpl :: ) => Push -> Sem r (Async (Maybe ())) -pushAsyncImpl p = async $ do +pushAsyncImpl p = async $ pushBestEffort p + +pushBestEffort :: + forall r. + ( Member GundeckAPIAccess r, + Member (Input NotificationSubsystemConfig) r, + Member P.Async r, + Member (Final IO) r, + Member P.TinyLog r + ) => + Push -> + Sem r () +pushBestEffort p = do reqId <- inputs requestId errorToIOFinal @SomeException (fromExceptionSem @SomeException $ pushImpl [p]) >>= \case Left e -> diff --git a/libs/wire-subsystems/src/Wire/Options/Galley.hs b/libs/wire-subsystems/src/Wire/Options/Galley.hs index 6b30fff0976..0ba9e542641 100644 --- a/libs/wire-subsystems/src/Wire/Options/Galley.hs +++ b/libs/wire-subsystems/src/Wire/Options/Galley.hs @@ -61,6 +61,10 @@ module Wire.Options.Galley checkGroupInfo, meetings, validityPeriod, + legacyTimeZone, + pastEditPeriod, + email, + MeetingsEmailConfig (..), postgresMigration, PostgresMigrationOpts (..), StorageLocation (..), @@ -70,7 +74,7 @@ module Wire.Options.Galley ) where -import Control.Lens hiding (Level, (.=)) +import Control.Lens hiding (Level, from, (.=)) import Data.Aeson (FromJSON (..)) import Data.Aeson.TH (deriveFromJSON) import Data.Domain (Domain) @@ -87,6 +91,8 @@ import Wire.API.Conversation.Protocol import Wire.API.Routes.Version import Wire.API.Team.FeatureFlags import Wire.API.Team.Member +import Wire.API.User.Identity (EmailAddress) +import Wire.EmailSending.Options (EmailOpts) import Wire.Options.Keys (MLSPrivateKeyPaths) import Wire.PostgresMigrationOpts import Wire.RateLimit.Interpreter (RateLimitConfig) @@ -138,7 +144,7 @@ data Settings = Settings -- -- multiIngress and conversationCodeURI are mutually exclusive. One of -- both options need to be configured. - _multiIngress :: Maybe (Map Text HttpsUrl), + _multiIngress :: Maybe (Map Domain HttpsUrl), -- | Throttling: limits to concurrent deletion events _concurrentDeletionEvents :: !(Maybe Int), -- | Throttling: delay between sending events upon team deletion @@ -176,10 +182,32 @@ data Settings = Settings data MeetingsConfig = MeetingsConfig { -- | Validity period of a meeting. After this time, the meeting is considered expired. - _validityPeriod :: !(Maybe Duration) + _validityPeriod :: !(Maybe Duration), + -- | How far in the past a meeting's start/end time may be moved by an + -- update. Must not exceed 'validityPeriod'. + _pastEditPeriod :: !(Maybe Duration), + -- | Email sending configuration for meeting invitations. When unset, no + -- meeting invitation emails are sent. + _email :: !(Maybe MeetingsEmailConfig), + -- | Default IANA time zone id (e.g. @"Europe/Berlin"@) injected when legacy + -- clients (< V17) create meetings that carry no @tzid@ (V17 adds it). + -- Resolved in 'Galley.App' via 'parseTimeZone'; defaults + -- to @"Europe/Berlin"@ when unset or invalid. + _legacyTimeZone :: !(Maybe Text) } deriving (Show, Generic) +data MeetingsEmailConfig = MeetingsEmailConfig + { -- | 'From' sender for meeting emails. Required when the block is present. + _from :: !EmailAddress, + -- | Optional 'Reply-To' address. + _replyTo :: !(Maybe EmailAddress), + -- | Outbound transport: AWS SES or SMTP ('Wire.EmailSending.Options.EmailOpts'). + _transport :: !EmailOpts + } + deriving (Show, Generic) + +deriveFromJSON toOptionFieldName ''MeetingsEmailConfig deriveFromJSON toOptionFieldName ''MeetingsConfig deriveFromJSON toOptionFieldName ''Settings @@ -243,7 +271,7 @@ deriveFromJSON toOptionFieldName ''Opts makeLenses ''Opts -conversationCodeURISettings :: (Applicative m) => Opts -> m (Either HttpsUrl (Map Text HttpsUrl)) +conversationCodeURISettings :: (Applicative m) => Opts -> m (Either HttpsUrl (Map Domain HttpsUrl)) conversationCodeURISettings opts = case (opts._settings._conversationCodeURI, opts._settings._multiIngress) of (Nothing, Nothing) -> error errMsg diff --git a/libs/wire-subsystems/src/Wire/Postgres.hs b/libs/wire-subsystems/src/Wire/Postgres.hs index d18de5f10a8..f91f3637efe 100644 --- a/libs/wire-subsystems/src/Wire/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/Postgres.hs @@ -70,15 +70,17 @@ where import Control.Monad.Trans.State import Data.Functor.Contravariant import Data.Id +import Data.Misc import Data.Text qualified as Text import Data.Text.Encoding qualified as Text -import Data.Time.Clock +import Data.Time.Clock (UTCTime) import Hasql.Decoders qualified as Dec import Hasql.Encoders qualified as Enc import Hasql.Errors import Hasql.Pipeline (Pipeline) -import Hasql.Pool import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt +import Hasql.Pool.Observer qualified as HasqlObserver import Hasql.Session import Hasql.Statement import Hasql.Transaction (Transaction) @@ -92,7 +94,7 @@ import PostgreSQL.ErrorCodes qualified as PostgreSQL import Wire.API.Pagination type PGConstraints r = - ( Member (Input Hasql.Pool) r, + ( Member (Input HasqlPoolExt.Pool) r, Member (Embed IO) r, Member (Error Hasql.UsageError) r ) @@ -100,8 +102,10 @@ type PGConstraints r = -- | Resets the pool if it detects server errors due to admin intervention. -- Things like server restart. Then retries the session. -- --- Inspired by https://github.com/nikita-volkov/hasql-pool/issues/27 -useWithResetAndRetry :: forall a. Pool -> Session a -> IO (Either UsageError a) +-- Inspired by https://github.com/nikita-volkov/hasql-pool/issues/27. +-- The old issue still describes the server-error retry pattern, even though +-- this module now uses hasql-resource-pool. +useWithResetAndRetry :: forall a. HasqlPoolExt.Pool -> Session a -> IO (Either Hasql.UsageError a) useWithResetAndRetry pool sess = go maxRetries where maxRetries :: Int @@ -110,19 +114,35 @@ useWithResetAndRetry pool sess = go maxRetries resettableErrors :: [ByteString] resettableErrors = [PostgreSQL.admin_shutdown, PostgreSQL.crash_shutdown, PostgreSQL.cannot_connect_now, PostgreSQL.database_dropped] - go :: Int -> IO (Either UsageError a) - go 0 = use pool sess + go :: Int -> IO (Either Hasql.UsageError a) + go 0 = useObservedNoRetry pool sess go n = do - eithRes <- use pool sess + eithRes <- useObservedNoRetry pool sess case eithRes of - Left (SessionUsageError (StatementSessionError _ _ _ _ _ (ServerStatementError (ServerError errCode _ _ _ _)))) -> do + Left (Hasql.SessionError (StatementSessionError _ _ _ _ _ (ServerStatementError (ServerError errCode _ _ _ _)))) -> do if (Text.encodeUtf8 errCode `elem` resettableErrors) then do - release pool + Hasql.release pool.rawPool go (n - 1) else pure eithRes _ -> pure eithRes + useObservedNoRetry :: HasqlPoolExt.Pool -> Session a -> IO (Either Hasql.UsageError a) + useObservedNoRetry p s = do + HasqlPoolExt.recordHasqlPoolSessionStarted p.metrics + result <- + Hasql.useWithObserverAndPoolAcquisitionTimeout + (Just \observed -> HasqlPoolExt.recordHasqlPoolSessionDuration p.metrics (realToFrac $ HasqlObserver.latency observed)) + (durationToCeilingSeconds p.poolAcquisitionTimeout) + p.rawPool + s + case result of + Left (Hasql.ConnectionError _) -> HasqlPoolExt.recordHasqlPoolConnectionFailure p.metrics + Left (Hasql.SessionError _) -> HasqlPoolExt.recordHasqlPoolSessionFailure p.metrics + Left Hasql.AcquisitionTimeoutUsageError -> HasqlPoolExt.recordHasqlPoolAcquisitionTimeout p.metrics + Right _ -> pure () + pure result + -- | Runs a 'Session' using the 'Hasql.Pool'. Retries on server errors due to -- admin intervention. Things like server restart. runSessionWithRetry :: @@ -142,7 +162,23 @@ runSessionWithRetry sess = do runSession :: (PGConstraints r) => Session a -> Sem r a runSession sess = do pool <- input - liftIO (use pool sess) >>= either throw pure + liftIO (useObserved pool sess) >>= either throw pure + +useObserved :: HasqlPoolExt.Pool -> Session a -> IO (Either Hasql.UsageError a) +useObserved pool sess = do + HasqlPoolExt.recordHasqlPoolSessionStarted pool.metrics + result <- + Hasql.useWithObserverAndPoolAcquisitionTimeout + (Just \observed -> HasqlPoolExt.recordHasqlPoolSessionDuration pool.metrics (realToFrac $ HasqlObserver.latency observed)) + (durationToCeilingSeconds pool.poolAcquisitionTimeout) + pool.rawPool + sess + case result of + Left (Hasql.ConnectionError _) -> HasqlPoolExt.recordHasqlPoolConnectionFailure pool.metrics + Left (Hasql.SessionError _) -> HasqlPoolExt.recordHasqlPoolSessionFailure pool.metrics + Left Hasql.AcquisitionTimeoutUsageError -> HasqlPoolExt.recordHasqlPoolAcquisitionTimeout pool.metrics + Right _ -> pure () + pure result -- | Runs a 'Statement' using the 'Hasql.Pool'. Always retries on server errors -- due to admin intervention. Things like server restart. diff --git a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs index d8b022b1ffd..cc0ccefa378 100644 --- a/libs/wire-subsystems/src/Wire/PostgresMigrations.hs +++ b/libs/wire-subsystems/src/Wire/PostgresMigrations.hs @@ -42,9 +42,16 @@ allMigrations = map (\(name, contentBS) -> MigrationScript name (Text.decodeUtf8 -- | Scripts which cannot be run in a transaction nonTransactionMigrations :: Set ScriptName -nonTransactionMigrations = Set.fromList ["20260428072649-create-conv-parent-index.sql"] - -data PostgresMigrationError = PostgresMigrationError MigrationError +nonTransactionMigrations = + Set.fromList + [ "20260428072649-create-conv-parent-index.sql", + "20260708090000-meetings-recurrence-eff-end-index.sql", + "20260708100000-meetings-end-time-nonrecurring-index.sql" + ] + +data PostgresMigrationError + = PostgresMigrationError MigrationError + | PostgresMigrationUsageError UsageError deriving (Show) instance Exception PostgresMigrationError @@ -71,7 +78,7 @@ runAllMigrations pool logger = do Just err -> throw $ PostgresMigrationError err Log.info logger $ Log.msg (Log.val "Migrations completed successfully") - either throwIO pure =<< use pool session + either (throwIO . PostgresMigrationUsageError) pure =<< use pool session where -- We must use `try` instead of blocking on the lock because running `CREATE -- INDEX CONCURRENTLY` requires all transactions to be complete and blocking @@ -120,4 +127,4 @@ resetSchema pool logger = do let session = do script "DROP SCHEMA IF EXISTS public CASCADE" script "CREATE SCHEMA IF NOT EXISTS public" - either throwIO pure =<< use pool session + either (throwIO . PostgresMigrationUsageError) pure =<< use pool session diff --git a/services/spar/src/Spar/Sem/Reporter.hs b/libs/wire-subsystems/src/Wire/Reporter.hs similarity index 94% rename from services/spar/src/Spar/Sem/Reporter.hs rename to libs/wire-subsystems/src/Wire/Reporter.hs index 77d71f0dce1..c5cc733b832 100644 --- a/services/spar/src/Spar/Sem/Reporter.hs +++ b/libs/wire-subsystems/src/Wire/Reporter.hs @@ -17,14 +17,14 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.Reporter +module Wire.Reporter ( Reporter (..), report, ) where import Imports -import qualified Network.Wai as Wai +import Network.Wai qualified as Wai import Network.Wai.Utilities.Error (Error) import Polysemy diff --git a/services/spar/src/Spar/Sem/Reporter/Wai.hs b/libs/wire-subsystems/src/Wire/Reporter/Wai.hs similarity index 88% rename from services/spar/src/Spar/Sem/Reporter/Wai.hs rename to libs/wire-subsystems/src/Wire/Reporter/Wai.hs index e2d0b66ef66..48e97849ef0 100644 --- a/services/spar/src/Spar/Sem/Reporter/Wai.hs +++ b/libs/wire-subsystems/src/Wire/Reporter/Wai.hs @@ -15,17 +15,17 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.Reporter.Wai +module Wire.Reporter.Wai ( reporterToTinyLogWai, ) where import Imports -import qualified Network.Wai.Utilities.Server as Wai +import Network.Wai.Utilities.Server qualified as Wai import Polysemy import Polysemy.Input -import Spar.Sem.Reporter -import qualified System.Logger as TinyLog +import System.Logger qualified as TinyLog +import Wire.Reporter reporterToTinyLogWai :: ( Member (Embed IO) r, diff --git a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs index 7204b12ccfa..243668fd312 100644 --- a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem.hs @@ -1,5 +1,22 @@ {-# LANGUAGE TemplateHaskell #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.SAMLEmailSubsystem where import Polysemy diff --git a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs index 453406eea3d..49321aaed0e 100644 --- a/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/SAMLEmailSubsystem/Interpreter.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.SAMLEmailSubsystem.Interpreter ( samlEmailSubsystemInterpreter, ) diff --git a/services/brig/src/Brig/Effects/SFT.hs b/libs/wire-subsystems/src/Wire/SFT.hs similarity index 99% rename from services/brig/src/Brig/Effects/SFT.hs rename to libs/wire-subsystems/src/Wire/SFT.hs index 9eccfd3e4c5..b86484fe008 100644 --- a/services/brig/src/Brig/Effects/SFT.hs +++ b/libs/wire-subsystems/src/Wire/SFT.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Brig.Effects.SFT +module Wire.SFT ( SFTError (..), SFTGetResponse (..), SFT (..), diff --git a/services/spar/src/Spar/Sem/SamlProtocolSettings.hs b/libs/wire-subsystems/src/Wire/SamlProtocolSettings.hs similarity index 91% rename from services/spar/src/Spar/Sem/SamlProtocolSettings.hs rename to libs/wire-subsystems/src/Wire/SamlProtocolSettings.hs index 8b0bb3f34c6..20397dc20cc 100644 --- a/services/spar/src/Spar/Sem/SamlProtocolSettings.hs +++ b/libs/wire-subsystems/src/Wire/SamlProtocolSettings.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.SamlProtocolSettings +module Wire.SamlProtocolSettings ( SamlProtocolSettings (..), spIssuer, responseURI, @@ -29,8 +29,8 @@ import Data.Domain import Data.Id (TeamId) import Imports import Polysemy -import qualified SAML2.WebSSO.Types as SAML -import qualified URI.ByteString as URI +import SAML2.WebSSO.Types qualified as SAML +import URI.ByteString qualified as URI data SamlProtocolSettings m a where SpIssuer :: Maybe TeamId -> Maybe Domain -> SamlProtocolSettings m (Maybe SAML.Issuer) diff --git a/services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs b/libs/wire-subsystems/src/Wire/SamlProtocolSettings/Servant.hs similarity index 91% rename from services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs rename to libs/wire-subsystems/src/Wire/SamlProtocolSettings/Servant.hs index 787700a46a8..fbd2c864016 100644 --- a/services/spar/src/Spar/Sem/SamlProtocolSettings/Servant.hs +++ b/libs/wire-subsystems/src/Wire/SamlProtocolSettings/Servant.hs @@ -18,16 +18,16 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.SamlProtocolSettings.Servant +module Wire.SamlProtocolSettings.Servant ( sparRouteToServant, ) where import Imports import Polysemy -import qualified SAML2.WebSSO as SAML -import Spar.Sem.SamlProtocolSettings +import SAML2.WebSSO qualified as SAML import Wire.API.Routes.Public.Spar +import Wire.SamlProtocolSettings sparRouteToServant :: SAML.Config -> Sem (SamlProtocolSettings ': r) a -> Sem r a sparRouteToServant cfg = interpret $ \case diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs similarity index 97% rename from services/spar/src/Spar/Sem/ScimExternalIdStore.hs rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs index c4bb2b54ed6..f88533358a4 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore.hs +++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimExternalIdStore +module Wire.ScimExternalIdStore ( ScimExternalIdStore (..), insert, lookup, @@ -32,7 +32,6 @@ import Data.Text import Imports (Maybe, Show) import Polysemy import Polysemy.Check (deriveGenericK) -import Spar.Scim.Types import Wire.API.User.Scim data ScimExternalIdStore m a where diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs similarity index 86% rename from services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs index 42d098dfe33..cf27706db00 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Cassandra.hs @@ -1,3 +1,4 @@ +{-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-} -- This file is part of the Wire Server implementation. @@ -17,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimExternalIdStore.Cassandra +module Wire.ScimExternalIdStore.Cassandra ( scimExternalIdStoreToCassandra, ) where @@ -27,10 +28,22 @@ import Data.Bifunctor (second) import Data.Id import Imports import Polysemy -import Spar.Data.Instances () -import Spar.Scim.Types (ScimUserCreationStatus (ScimUserCreated)) -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore (..)) -import Wire.API.User.Scim (ValidScimId (..)) +import Wire.API.User.Scim (ScimUserCreationStatus (..), ValidScimId (..)) +import Wire.ScimExternalIdStore (ScimExternalIdStore (..)) + +-- This is the only consumer of the @scim_external.creation_status@ column, +-- so the Cql instance lives here. +instance Cql ScimUserCreationStatus where + ctype = Tagged IntColumn + + toCql ScimUserCreated = CqlInt 0 + toCql ScimUserCreating = CqlInt 1 + + fromCql (CqlInt i) = case i of + 0 -> pure ScimUserCreated + 1 -> pure ScimUserCreating + n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n + fromCql _ = Left "int expected" scimExternalIdStoreToCassandra :: forall m r a. diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs similarity index 88% rename from services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs rename to libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs index 5ab14ccd4af..7f35b69d7f3 100644 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Mem.hs +++ b/libs/wire-subsystems/src/Wire/ScimExternalIdStore/Mem.hs @@ -17,19 +17,18 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimExternalIdStore.Mem +module Wire.ScimExternalIdStore.Mem ( scimExternalIdStoreToMem, ) where import Data.Id (TeamId, UserId) -import qualified Data.Map as M +import Data.Map qualified as M import Imports import Polysemy import Polysemy.State -import Spar.Scim.Types (ScimUserCreationStatus) -import Spar.Sem.ScimExternalIdStore -import Wire.API.User.Scim (ValidScimId (..)) +import Wire.API.User.Scim (ScimUserCreationStatus, ValidScimId (..)) +import Wire.ScimExternalIdStore scimExternalIdStoreToMem :: Sem (ScimExternalIdStore ': r) a -> diff --git a/services/spar/src/Spar/Sem/ScimUserTimesStore.hs b/libs/wire-subsystems/src/Wire/ScimUserMetaStore.hs similarity index 50% rename from services/spar/src/Spar/Sem/ScimUserTimesStore.hs rename to libs/wire-subsystems/src/Wire/ScimUserMetaStore.hs index 0efb17527bd..50a62c231be 100644 --- a/services/spar/src/Spar/Sem/ScimUserTimesStore.hs +++ b/libs/wire-subsystems/src/Wire/ScimUserMetaStore.hs @@ -17,8 +17,9 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimUserTimesStore - ( ScimUserTimesStore (..), +module Wire.ScimUserMetaStore + ( ScimUserMeta (..), + ScimUserMetaStore (..), write, read, readMulti, @@ -28,15 +29,28 @@ where import Data.Id (UserId) import Data.Json.Util (UTCTimeMillis) -import Imports (Maybe) +import Imports (Bool, Eq, Maybe, Show, Text) import Polysemy import Web.Scim.Schema.Common (WithId) import Web.Scim.Schema.Meta (WithMeta) -data ScimUserTimesStore m a where - Write :: WithMeta (WithId UserId t) -> ScimUserTimesStore m () - Read :: UserId -> ScimUserTimesStore m (Maybe (UTCTimeMillis, UTCTimeMillis)) - ReadMulti :: [UserId] -> ScimUserTimesStore m [(UserId, UTCTimeMillis, UTCTimeMillis)] - Delete :: UserId -> ScimUserTimesStore m () +-- | SCIM user metadata stored under a user id: creation and last-update time, +-- plus the SCIM email metadata (@type@, @primary@) of the stored email entry. +-- +-- The backing Cassandra table is still called @spar.scim_user_times@ (renaming +-- it would require a migration); the store is no longer just about times. +data ScimUserMeta = ScimUserMeta + { scimUserMetaCreated :: UTCTimeMillis, + scimUserMetaLastUpdated :: UTCTimeMillis, + scimUserMetaEmailType :: Maybe Text, + scimUserMetaEmailPrimary :: Maybe Bool + } + deriving (Eq, Show) + +data ScimUserMetaStore m a where + Write :: Maybe Text -> Maybe Bool -> WithMeta (WithId UserId t) -> ScimUserMetaStore m () + Read :: UserId -> ScimUserMetaStore m (Maybe ScimUserMeta) + ReadMulti :: [UserId] -> ScimUserMetaStore m [(UserId, ScimUserMeta)] + Delete :: UserId -> ScimUserMetaStore m () -makeSem ''ScimUserTimesStore +makeSem ''ScimUserMetaStore diff --git a/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Cassandra.hs new file mode 100644 index 00000000000..591c03fac92 --- /dev/null +++ b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Cassandra.hs @@ -0,0 +1,102 @@ +-- Disabling to stop warnings on HasCallStack +{-# OPTIONS_GHC -Wno-redundant-constraints #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.ScimUserMetaStore.Cassandra + ( scimUserMetaStoreToCassandra, + ) +where + +import Cassandra as Cas +import Data.Id +import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis) +import Imports +import Polysemy +import Web.Scim.Schema.Common (WithId (..)) +import Web.Scim.Schema.Meta (Meta (..), WithMeta (..)) +import Wire.ScimUserMetaStore (ScimUserMeta (..), ScimUserMetaStore (..)) + +scimUserMetaStoreToCassandra :: forall m r a. (MonadClient m, Member (Embed m) r) => Sem (ScimUserMetaStore ': r) a -> Sem r a +scimUserMetaStoreToCassandra = + interpret $ + embed @m . \case + Write emailType emailPrimary wm -> writeScimUserMeta emailType emailPrimary wm + Read uid -> readScimUserMeta uid + ReadMulti uids -> readScimUserMetaMulti uids + Delete uid -> deleteScimUserMeta uid + +---------------------------------------------------------------------- +-- SCIM user records +-- +-- docs/developer/scim/storage.md {#DevScimStorageUsers} + +-- | Store creation and last-update time from the scim metadata under a user +-- id, together with the SCIM email metadata (@type@, @primary@) of the stored +-- email entry (if any was supplied by the IdP). +writeScimUserMeta :: (HasCallStack, MonadClient m) => Maybe Text -> Maybe Bool -> WithMeta (WithId UserId a) -> m () +writeScimUserMeta emailType emailPrimary (WithMeta meta (WithId uid _)) = + retry x5 . write ins $ + params + LocalQuorum + ( uid, + toUTCTimeMillis $ created meta, + toUTCTimeMillis $ lastModified meta, + emailType, + emailPrimary + ) + where + ins :: PrepQuery W (UserId, UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) () + ins = "INSERT INTO scim_user_times (uid, created_at, last_updated_at, email_type, email_primary) VALUES (?, ?, ?, ?, ?)" + +-- | Read creation and last-update time (and SCIM email metadata) from the +-- database for a given user id. +readScimUserMeta :: (HasCallStack, MonadClient m) => UserId -> m (Maybe ScimUserMeta) +readScimUserMeta uid = do + fmap rowToScimUserMeta + <$> retry x1 (query1 sel $ params LocalQuorum (Identity uid)) + where + sel :: PrepQuery R (Identity UserId) (UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) + sel = "SELECT created_at, last_updated_at, email_type, email_primary FROM scim_user_times WHERE uid = ?" + +readScimUserMetaMulti :: (HasCallStack, MonadClient m) => [UserId] -> m [(UserId, ScimUserMeta)] +readScimUserMetaMulti uid = do + fmap rowToUserMetaMulti + <$> retry x1 (query sel $ params LocalQuorum (Identity uid)) + where + sel :: PrepQuery R (Identity [UserId]) (UserId, UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) + sel = "SELECT uid, created_at, last_updated_at, email_type, email_primary FROM scim_user_times WHERE uid IN ?" + +rowToScimUserMeta :: (UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) -> ScimUserMeta +rowToScimUserMeta (created_, lastUpdated, emailType, emailPrimary) = + ScimUserMeta created_ lastUpdated emailType emailPrimary + +rowToUserMetaMulti :: (UserId, UTCTimeMillis, UTCTimeMillis, Maybe Text, Maybe Bool) -> (UserId, ScimUserMeta) +rowToUserMetaMulti (uid, created_, lastUpdated, emailType, emailPrimary) = + (uid, ScimUserMeta created_ lastUpdated emailType emailPrimary) + +-- | Delete a SCIM user's access times by id. +-- You'll also want to ensure they are deleted in Brig and in the SAML Users table. +deleteScimUserMeta :: + (HasCallStack, MonadClient m) => + UserId -> + m () +deleteScimUserMeta uid = retry x5 . write del $ params LocalQuorum (Identity uid) + where + del :: PrepQuery W (Identity UserId) () + del = "DELETE FROM scim_user_times WHERE uid = ?" diff --git a/services/spar/src/Spar/Sem/ScimUserTimesStore/Mem.hs b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Mem.hs similarity index 63% rename from services/spar/src/Spar/Sem/ScimUserTimesStore/Mem.hs rename to libs/wire-subsystems/src/Wire/ScimUserMetaStore/Mem.hs index fa5f027d8a4..2fb47f02c62 100644 --- a/services/spar/src/Spar/Sem/ScimUserTimesStore/Mem.hs +++ b/libs/wire-subsystems/src/Wire/ScimUserMetaStore/Mem.hs @@ -17,27 +17,34 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.ScimUserTimesStore.Mem - ( scimUserTimesStoreToMem, +module Wire.ScimUserMetaStore.Mem + ( scimUserMetaStoreToMem, ) where import Data.Id (UserId) -import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis) -import qualified Data.Map as M +import Data.Json.Util (toUTCTimeMillis) +import Data.Map qualified as M import Imports import Polysemy import Polysemy.State -import Spar.Sem.ScimUserTimesStore import Web.Scim.Schema.Common (WithId (WithId)) import Web.Scim.Schema.Meta (WithMeta (WithMeta), created, lastModified) +import Wire.ScimUserMetaStore -scimUserTimesStoreToMem :: - Sem (ScimUserTimesStore ': r) a -> - Sem r (Map UserId (UTCTimeMillis, UTCTimeMillis), a) -scimUserTimesStoreToMem = (runState mempty .) $ +scimUserMetaStoreToMem :: + Sem (ScimUserMetaStore ': r) a -> + Sem r (Map UserId ScimUserMeta, a) +scimUserMetaStoreToMem = (runState mempty .) $ reinterpret $ \case - Write (WithMeta meta (WithId uid _)) -> modify $ M.insert uid (toUTCTimeMillis $ created meta, toUTCTimeMillis $ lastModified meta) + Write emailType emailPrimary (WithMeta meta (WithId uid _)) -> + modify $ + M.insert uid $ + ScimUserMeta + (toUTCTimeMillis $ created meta) + (toUTCTimeMillis $ lastModified meta) + emailType + emailPrimary Read uid -> gets $ M.lookup uid - ReadMulti uids -> gets $ map (\(u, (a, b)) -> (u, a, b)) . filter ((`elem` uids) . fst) . M.toList + ReadMulti uids -> gets $ filter ((`elem` uids) . fst) . M.toList Delete uid -> modify $ M.delete uid diff --git a/libs/wire-subsystems/src/Wire/StoredUser.hs b/libs/wire-subsystems/src/Wire/StoredUser.hs index 414598883f3..d4aa2fc0367 100644 --- a/libs/wire-subsystems/src/Wire/StoredUser.hs +++ b/libs/wire-subsystems/src/Wire/StoredUser.hs @@ -205,7 +205,6 @@ data NewStoredUser = NewStoredUser country :: Maybe Country, providerId :: Maybe ProviderId, serviceId :: Maybe ServiceId, - handle :: Maybe Handle, teamId :: Maybe TeamId, managedBy :: ManagedBy, supportedProtocols :: Set BaseProtocolTag, @@ -238,7 +237,6 @@ deriving instance Maybe Country, Maybe ProviderId, Maybe ServiceId, - Maybe Handle, Maybe TeamId, ManagedBy, Set BaseProtocolTag, @@ -268,7 +266,7 @@ newStoredUserToStoredUser new = country = new.country, providerId = new.providerId, serviceId = new.serviceId, - handle = new.handle, + handle = Nothing, teamId = new.teamId, managedBy = Just new.managedBy, supportedProtocols = Just new.supportedProtocols, @@ -277,8 +275,8 @@ newStoredUserToStoredUser new = -- This saves the identity from `NewStoredUser` even if the user is -- not activated. -newStoredUserToUser :: Qualified NewStoredUser -> User -newStoredUserToUser (Qualified new domain) = +newStoredUserToUser :: Qualified NewStoredUser -> Maybe Handle -> User +newStoredUserToUser (Qualified new domain) mbHandle = User { userQualifiedId = Qualified new.id domain, userType = new.userType, @@ -292,7 +290,7 @@ newStoredUserToUser (Qualified new domain) = userStatus = new.status, userLocale = Locale new.language new.country, userService = newServiceRef <$> new.serviceId <*> new.providerId, - userHandle = new.handle, + userHandle = mbHandle, userExpire = new.expires, userTeam = new.teamId, userManagedBy = new.managedBy, diff --git a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs index b454f1380d1..a6a1e968a72 100644 --- a/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/TeamCollaboratorsStore/Postgres.hs @@ -29,21 +29,17 @@ import Data.Set import Data.Set qualified as Set import Data.UUID import Data.Vector hiding (mapM) -import Hasql.Pool import Hasql.Statement import Hasql.TH import Imports import Polysemy import Polysemy.Error (Error, throw) -import Polysemy.Input import Wire.API.Team.Collaborator import Wire.Postgres import Wire.TeamCollaboratorsStore interpretTeamCollaboratorsStoreToPostgres :: - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r, + ( PGConstraints r, Member (Error TeamCollaboratorsError) r ) => InterpreterFor TeamCollaboratorsStore r @@ -101,10 +97,7 @@ createTeamCollaboratorImpl userId teamId permissions = do |] getAllTeamCollaboratorsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => TeamId -> Sem r [TeamCollaborator] getAllTeamCollaboratorsImpl teamId = do @@ -118,10 +111,7 @@ getAllTeamCollaboratorsImpl teamId = do |] updateTeamCollaboratorImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Set CollaboratorPermission -> @@ -140,10 +130,7 @@ updateTeamCollaboratorImpl userId teamId permissions = do |] removeTeamCollaboratorImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> TeamId -> Sem r () @@ -181,10 +168,7 @@ postgreslRepToCollaboratorPermission = (collaboratorPermissionMap Bimap.! {- `!` throws if the element isn't found -}) getTeamCollaborationsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => UserId -> Sem r [TeamCollaborator] getTeamCollaborationsImpl teamId = do @@ -198,10 +182,7 @@ getTeamCollaborationsImpl teamId = do |] getTeamCollaboratorsWithIdsImpl :: - ( Member (Input Pool) r, - Member (Embed IO) r, - Member (Error UsageError) r - ) => + (PGConstraints r) => Set TeamId -> Set UserId -> Sem r [TeamCollaborator] diff --git a/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs b/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs index 060fd86b38a..1edeb7daf2c 100644 --- a/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs +++ b/libs/wire-subsystems/src/Wire/TeamFeatureStore/Migration.hs @@ -22,7 +22,8 @@ import Data.ByteString.Conversion import Data.Conduit import Data.Conduit.List qualified as C import Data.Id -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) +import Hasql.Pool.Extended qualified as Hasql import Imports import Polysemy import Polysemy.Async @@ -137,7 +138,7 @@ handleErrors :: Member TinyLog r ) => ByteString -> - (Sem (Error MigrationLockError : Error Hasql.UsageError : r) ()) -> + (Sem (Error MigrationLockError : Error UsageError : r) ()) -> Sem r () handleErrors key action = do eithErr <- runError (runError action) diff --git a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs index d7e7b9e4682..82002248f68 100644 --- a/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/TeamInvitationSubsystem/Interpreter.hs @@ -54,11 +54,14 @@ import Wire.Sem.Now (Now) import Wire.Sem.Now qualified as Now import Wire.Sem.Random (Random) import Wire.Sem.Random qualified as Random +import Wire.StoredUser (StoredUser (email, managedBy, status, teamId)) import Wire.TeamInvitationSubsystem import Wire.TeamInvitationSubsystem.Error import Wire.TeamSubsystem import Wire.UserKeyStore -import Wire.UserSubsystem (UserSubsystem, getLocalUserAccountByUserKey, getSelfProfile, isBlocked) +import Wire.UserStore (UserStore) +import Wire.UserStore qualified as UserStore +import Wire.UserSubsystem (UserSubsystem, getAccountNoFilter, getLocalUserAccountByUserKey, getSelfProfile, isBlocked) data TeamInvitationSubsystemConfig = TeamInvitationSubsystemConfig { maxTeamSize :: Word32, @@ -75,6 +78,7 @@ runTeamInvitationSubsystem :: Member UserSubsystem r, Member Random r, Member InvitationStore r, + Member UserStore r, Member Now r, Member EmailSubsystem r, Member EnterpriseLoginSubsystem r, @@ -88,6 +92,12 @@ runTeamInvitationSubsystem cfg = interpret $ \case InternalCreateInvitation tid mExpectedInvId role mbInviterUid inviterEmail invRequest -> runInputConst cfg $ createInvitation' tid mExpectedInvId role mbInviterUid inviterEmail invRequest +data ScimInvitationState + = ScimInvitationConflict + | ScimInvitationExpired UserId + | ScimInvitationStale UserId + deriving (Eq, Show) + inviteUserImpl :: ( Member (Error TeamInvitationSubsystemError) r, Member GalleyAPIAccess r, @@ -100,7 +110,8 @@ inviteUserImpl :: Member EmailSubsystem r, Member EnterpriseLoginSubsystem r, Member TeamSubsystem r, - Member UserKeyStore r + Member UserKeyStore r, + Member UserStore r ) => Local UserId -> TeamId -> @@ -111,6 +122,7 @@ inviteUserImpl luid tid request = do let inviteePerms = Teams.rolePermissions inviteeRole ensurePermissionToAddUser (tUnqualified luid) tid inviteePerms + reconcileScimInvitation request.inviteeEmail inviterEmail <- note TeamInvitationNoEmail =<< runMaybeT do @@ -131,6 +143,63 @@ inviteUserImpl luid tid request = do loc inv = InvitationLocation $ "/teams/" <> toByteString' tid <> "/invitations/" <> toByteString' inv.invitationId + reconcileScimInvitation email = do + pendingScimUsers <- Store.lookupPendingScimUsers tid email + invitations <- + if null pendingScimUsers + then pure [] + else Store.lookupInvitationsByEmail email + invitationStates <- traverse (classifyScimUser email invitations) pendingScimUsers + + for_ invitationStates $ \case + ScimInvitationExpired uid -> cleanupExpiredScimUser email uid + ScimInvitationStale uid -> Store.deletePendingScimUser tid email uid + ScimInvitationConflict -> pure () + + when (ScimInvitationConflict `elem` invitationStates) $ + throw TeamInvitationEmailTaken + + classifyScimUser requestedEmail invitations uid = do + mStoredUser <- UserStore.getUser uid + pure $ case mStoredUser of + Nothing -> ScimInvitationStale uid + Just storedUser + | storedUser.teamId /= Just tid + || storedUser.email /= Just requestedEmail + || storedUser.managedBy /= Just ManagedByScim -> + ScimInvitationStale uid + | otherwise -> + case storedUser.status of + Just PendingInvitation -> + if (not (any (invitationIsLive uid) invitations)) + then + -- Only a matching pending SCIM account can be cleaned + -- up when its invitation has expired. + ScimInvitationExpired uid + else + -- The SCIM invitation is still usable, so the existing + -- pending account must not be deleted or replaced. + ScimInvitationConflict + _ -> + -- An active SCIM account must continue to block a manual + -- invitation, even if the index was not removed on activation. + ScimInvitationConflict + + invitationIsLive uid inv = + inv.teamId == tid + && invitationIdToUserId inv.invitationId == uid + + cleanupExpiredScimUser requestedEmail uid = do + -- Delete the account synchronously so UserStore releases its handle before + -- the manual invitation is created. Keep the index entry if deletion fails. + mUser <- getAccountNoFilter (qualifyAs luid uid) + case mUser of + Nothing -> pure () + Just user -> do + UserStore.deleteUser user + deleteKeyForUser uid (mkEmailKey requestedEmail) + Store.deletePendingScimUser tid requestedEmail uid + createInvitation' :: ( Member GalleyAPIAccess r, Member UserSubsystem r, diff --git a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs index 6702f5a41f4..bc034c009c4 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupStore/Postgres.hs @@ -32,7 +32,6 @@ import Data.UUID as UUID import Data.Vector (Vector) import Data.Vector qualified as V import Hasql.Decoders qualified as HD -import Hasql.Pool import Hasql.Session import Hasql.Statement import Hasql.TH @@ -40,7 +39,6 @@ import Hasql.Transaction qualified as Tx import Hasql.Transaction.Sessions qualified as TxSessions import Imports import Polysemy -import Polysemy.Error (Error) import Polysemy.Input import Wire.API.Pagination import Wire.API.PostgresMarshall @@ -52,10 +50,7 @@ import Wire.Postgres import Wire.UserGroupStore (UserGroupStore (..)) type UserGroupStorePostgresEffectConstraints r = - ( Member (Embed IO) r, - Member (Input Pool) r, - Member (Error UsageError) r - ) + PGConstraints r interpretUserGroupStoreToPostgres :: forall r. diff --git a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs index 7e6b9ba959b..59565c35747 100644 --- a/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserGroupSubsystem/Interpreter.hs @@ -65,7 +65,7 @@ interpretUserGroupSubsystem :: Member NotificationSubsystem r, Member TeamSubsystem r, Member GalleyAPIAccess r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => InterpreterFor UserGroupSubsystem r interpretUserGroupSubsystem = interpret $ \case @@ -113,7 +113,7 @@ createUserGroup :: Member (Input (Local ())) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> NewUserGroup -> @@ -131,7 +131,7 @@ createUserGroupFullImpl :: Member NotificationSubsystem r, Member TeamSubsystem r, Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => ManagedBy -> TeamId {- home team of the user group.-} -> @@ -368,7 +368,7 @@ addUser :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -393,7 +393,7 @@ addUsers :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -422,7 +422,7 @@ updateUsers :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -438,7 +438,7 @@ updateUsersNoAccessControl :: Member NotificationSubsystem r, Member TeamSubsystem r, Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => TeamId -> Maybe UserId -> @@ -463,7 +463,7 @@ removeUser :: Member (Error UserGroupSubsystemError) r, Member NotificationSubsystem r, Member TeamSubsystem r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UserId -> UserGroupId -> @@ -525,7 +525,7 @@ updateChannels :: Member TeamSubsystem r, Member NotificationSubsystem r, Member GalleyAPIAccess r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => Bool -> UserId -> @@ -554,7 +554,7 @@ updateChannels appendOnly performer groupId channelIds = do triggerSyncUserGroup :: ( Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => TeamId -> Maybe UserId -> @@ -562,7 +562,7 @@ triggerSyncUserGroup :: Sem r () triggerSyncUserGroup teamId actor userGroupId = do jobId <- Random.newId - publishJob jobId $ JobSyncUserGroup SyncUserGroup {..} + publishJob jobId $ BackgroundJobSyncUserGroup SyncUserGroup {..} resetUserGroupInternal :: ( Member Store.UserGroupStore r, @@ -570,7 +570,7 @@ resetUserGroupInternal :: Member TeamSubsystem r, Member NotificationSubsystem r, Member Random.Random r, - Member BackgroundJobsPublisher r + Member BackgroundJobPublisher r ) => UpdateGroupInternalRequest -> Sem r () diff --git a/services/brig/src/Brig/Effects/UserPendingActivationStore.hs b/libs/wire-subsystems/src/Wire/UserPendingActivationStore.hs similarity index 96% rename from services/brig/src/Brig/Effects/UserPendingActivationStore.hs rename to libs/wire-subsystems/src/Wire/UserPendingActivationStore.hs index cd879e6f36d..1bf1a4c23ee 100644 --- a/services/brig/src/Brig/Effects/UserPendingActivationStore.hs +++ b/libs/wire-subsystems/src/Wire/UserPendingActivationStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Brig.Effects.UserPendingActivationStore where +module Wire.UserPendingActivationStore where import Data.Id import Data.Time.Clock diff --git a/services/brig/src/Brig/Effects/UserPendingActivationStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserPendingActivationStore/Cassandra.hs similarity index 96% rename from services/brig/src/Brig/Effects/UserPendingActivationStore/Cassandra.hs rename to libs/wire-subsystems/src/Wire/UserPendingActivationStore/Cassandra.hs index 83dc7fe20b7..a458ab0a594 100644 --- a/services/brig/src/Brig/Effects/UserPendingActivationStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/UserPendingActivationStore/Cassandra.hs @@ -17,12 +17,11 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Brig.Effects.UserPendingActivationStore.Cassandra +module Wire.UserPendingActivationStore.Cassandra ( userPendingActivationStoreToCassandra, ) where -import Brig.Effects.UserPendingActivationStore import Cassandra import Data.Id (UserId) import Data.Time (UTCTime) @@ -30,6 +29,7 @@ import Imports import Polysemy import Polysemy.Internal.Tactics import Wire.Sem.Paging.Cassandra qualified as PC +import Wire.UserPendingActivationStore userPendingActivationStoreToCassandra :: forall r a. diff --git a/libs/wire-subsystems/src/Wire/UserStore.hs b/libs/wire-subsystems/src/Wire/UserStore.hs index 8545d4c563d..46bc9e2ff57 100644 --- a/libs/wire-subsystems/src/Wire/UserStore.hs +++ b/libs/wire-subsystems/src/Wire/UserStore.hs @@ -105,7 +105,6 @@ data UserStore m a where -- GetUsersTeams :: [UserId] -> UserStore m (Maybe [TeamId]) UpdateUserTeam :: UserId -> TeamId -> UserStore m () GetRichInfo :: UserId -> UserStore m (Maybe RichInfoAssocList) - LookupRichInfos :: [UserId] -> UserStore m [(UserId, RichInfo)] UpdateRichInfo :: UserId -> RichInfoAssocList -> UserStore m () UpsertHashedPassword :: UserId -> Password -> UserStore m () LookupHashedPassword :: UserId -> UserStore m (Maybe Password) diff --git a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs index bc919cd854d..6226347fece 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Cassandra.hs @@ -70,7 +70,6 @@ interpretUserStoreCassandra casClient = GetUserTeam uid -> getUserTeamImpl uid UpdateUserTeam uid tid -> updateUserTeamImpl uid tid GetRichInfo uid -> getRichInfoImpl uid - LookupRichInfos uids -> lookupRichInfosImpl uids UpsertHashedPassword uid pw -> upsertHashedPasswordImpl uid pw LookupHashedPassword uid -> lookupHashedPasswordImpl uid GetUserAuthenticationInfo uid -> getUserAuthenticationInfoImpl uid @@ -256,15 +255,6 @@ lookupNameImpl u = nameSelect :: PrepQuery R (Identity UserId) (Identity Name) nameSelect = "SELECT name FROM user WHERE id = ?" --- | Returned rich infos are in the same order as users -lookupRichInfosImpl :: (MonadClient m) => [UserId] -> m [(UserId, RichInfo)] -lookupRichInfosImpl users = do - mapMaybe (\(uid, mbRi) -> (uid,) . RichInfo <$> mbRi) - <$> retry x1 (query richInfoSelectMulti (params LocalQuorum (Identity users))) - where - richInfoSelectMulti :: PrepQuery R (Identity [UserId]) (UserId, Maybe RichInfoAssocList) - richInfoSelectMulti = "SELECT user, json FROM rich_info WHERE user in ?" - lookupFeatureConferenceCallingImpl :: (MonadClient m) => UserId -> m (Maybe FeatureStatus) lookupFeatureConferenceCallingImpl uid = do let q = query1 select (params LocalQuorum (Identity uid)) @@ -453,8 +443,8 @@ insertUser :: PrepQuery W (TupleType NewStoredUser) () insertUser = "INSERT INTO user (id, user_type, name, text_status, picture, assets, email, sso_id, \ \accent_id, password, activated, status, expires, language, \ - \country, provider, service, handle, team, managed_by, supported_protocols, searchable) \ - \VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + \country, provider, service, team, managed_by, supported_protocols, searchable) \ + \VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" insertServiceUser :: PrepQuery W (ProviderId, ServiceId, BotId, ConvId, Maybe TeamId) () insertServiceUser = diff --git a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs index 9192317b407..88177b95cde 100644 --- a/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs +++ b/libs/wire-subsystems/src/Wire/UserStore/Postgres.hs @@ -1,6 +1,23 @@ {-# LANGUAGE RecordWildCards #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.UserStore.Postgres (interpretUserStorePostgres) where import Cassandra (GeneralPaginationState (PaginationStatePostgres), PageWithState (..), paginationStatePostgres) @@ -68,7 +85,6 @@ interpretUserStorePostgres = GetUserTeam uid -> getUserTeamImpl uid UpdateUserTeam uid tid -> updateUserTeamImpl uid tid GetRichInfo uid -> getRichInfoImpl uid - LookupRichInfos uids -> lookupRichInfosImpl uids UpsertHashedPassword uid pw -> upsertHashedPasswordImpl uid pw LookupHashedPassword uid -> lookupHashedPasswordImpl uid GetUserAuthenticationInfo uid -> getUserAuthenticationInfoImpl uid @@ -82,8 +98,7 @@ type InsertUserRow = ( UserId, Name, Maybe TextStatus, Pict, Maybe EmailAddress, Maybe UserSSOId, ColourId, Maybe Password, Bool, AccountStatus, Maybe UTCTimeMillis, Language, Maybe Country, Maybe ProviderId, Maybe ServiceId, - Maybe Handle, Maybe TeamId, ManagedBy, Set BaseProtocolTag, Bool, - UserType + Maybe TeamId, ManagedBy, Set BaseProtocolTag, Bool, UserType ) type SelectUserRow = @@ -166,7 +181,6 @@ createUserImpl new mbConv = new.country, new.providerId, new.serviceId, - new.handle, new.teamId, new.managedBy, new.supportedProtocols, @@ -182,14 +196,12 @@ createUserImpl new mbConv = (id, name, text_status, picture, email, sso_id, accent_id, password, activated, account_status, expires, language, country, provider, service, - handle, team, managed_by, supported_protocols, searchable, - user_type) + team, managed_by, supported_protocols, searchable, user_type) VALUES ($1 :: uuid, $2 :: text, $3 :: text?, $4 :: jsonb, $5 :: text?, $6 :: jsonb?, $7 :: integer, $8 :: text?, $9 :: boolean, $10 :: integer, $11 :: timestamptz?, $12 :: text, $13 :: text?, $14 :: uuid?, $15 :: uuid?, - $16 :: text?, $17 :: uuid?, $18 :: integer, $19 :: integer, $20 :: boolean, - $21 :: integer) + $16 :: uuid?, $17 :: integer, $18 :: integer, $19 :: boolean, $20 :: integer) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, text_status = EXCLUDED.text_status, @@ -205,7 +217,6 @@ createUserImpl new mbConv = country = EXCLUDED.country, provider = EXCLUDED.provider, service = EXCLUDED.service, - handle = EXCLUDED.handle, team = EXCLUDED.team, managed_by = EXCLUDED.managed_by, supported_protocols = EXCLUDED.supported_protocols, @@ -691,15 +702,6 @@ updateRichInfoImpl uid richInfo = dimapPG [resultlessStatement|UPDATE wire_user SET rich_info = $2 :: jsonb WHERE id = $1 :: uuid|] -lookupRichInfosImpl :: (PGConstraints r) => [UserId] -> Sem r [(UserId, RichInfo)] -lookupRichInfosImpl uids = - mapMaybe (\(uid, mbRi) -> (uid,) . RichInfo <$> mbRi) <$> runStatement uids select - where - select :: Hasql.Statement [UserId] [(UserId, Maybe RichInfoAssocList)] - select = - dimapPG @(Vector _) - [vectorStatement|SELECT id :: uuid, rich_info :: json? FROM wire_user WHERE id = ANY($1 :: uuid[])|] - upsertHashedPasswordImpl :: (PGConstraints r) => UserId -> Password -> Sem r () upsertHashedPasswordImpl uid pw = runStatement (uid, pw) upsert where diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem.hs b/libs/wire-subsystems/src/Wire/UserSubsystem.hs index 3980bbf6b8f..0f5f428ae73 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem.hs @@ -43,6 +43,7 @@ import SAML2.WebSSO qualified as SAML import Text.Email.Parser import Wire.API.EnterpriseLogin import Wire.API.Federation.Error +import Wire.API.MLS.CipherSuite (CipherSuiteTag) import Wire.API.Routes.Internal.Brig (GetBy (..), getByNoFilters) import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus) import Wire.API.Team.Export (TeamExportUser) @@ -50,6 +51,7 @@ import Wire.API.Team.Feature import Wire.API.Team.Member (IsPerm (..), TeamMember) import Wire.API.User import Wire.API.User.Activation +import Wire.API.User.Client (Client) import Wire.API.User.IdentityProvider hiding (domain, team) import Wire.API.User.Search import Wire.ActivationCodeStore @@ -130,7 +132,7 @@ data UserSubsystem m a where -- | Sometimes we don't have any identity of a requesting user, and local profiles are public. GetLocalUserProfiles :: Local [UserId] -> UserSubsystem m [UserProfile] -- | Get profiles for all app users in a team, touching only the apps table (efficient). - GetLocalAppProfiles :: Local TeamId -> UserSubsystem m [UserProfile] + GetLocalAppProfiles :: Local UserId -> TeamId -> UserSubsystem m [UserProfile] -- | Get the union of all user accounts matching the `GetBy` argument *and* having a non-empty UserIdentity. GetAccountsBy :: Local GetBy -> UserSubsystem m [User] -- | Get user accounts matching the `[EmailAddress]` argument (accounts with missing @@ -167,6 +169,7 @@ data UserSubsystem m a where Maybe (Range 1 500 Int32) -> Maybe [UserTypeFilter] -> UserSubsystem m (SearchResult Contact) + IsUsersContactable :: Map UserId (Set BaseProtocolTag, Set Client) -> Bool -> Set CipherSuiteTag -> UserSubsystem m (Map UserId Bool) BrowseTeam :: UserId -> BrowseTeamFilters -> diff --git a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs index 42ec197d844..d5cb2dfee62 100644 --- a/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs +++ b/libs/wire-subsystems/src/Wire/UserSubsystem/Interpreter.hs @@ -57,6 +57,7 @@ import Wire.API.EnterpriseLogin import Wire.API.Federation.API import Wire.API.Federation.API.Brig qualified as FedBrig import Wire.API.Federation.Error +import Wire.API.MLS.CipherSuite (CipherSuiteTag, csSignatureScheme) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti (TeamStatus (..)) import Wire.API.Team.Export @@ -68,6 +69,7 @@ import Wire.API.Team.Role (Role, defaultRole, permissionsToRole) import Wire.API.Team.SearchVisibility import Wire.API.Team.Size import Wire.API.User as User +import Wire.API.User.Client qualified as UserClient import Wire.API.User.RichInfo import Wire.API.User.Search import Wire.API.UserEvent @@ -77,6 +79,8 @@ import Wire.AppSubsystem import Wire.AppSubsystem.Interpreter import Wire.AuthenticationSubsystem import Wire.BlockListStore as BlockList +import Wire.ClientStore (ClientStore) +import Wire.ClientStore qualified as ClientStore import Wire.ClientSubsystem (ClientSubsystem) import Wire.ClientSubsystem qualified as ClientSubsystem import Wire.DeleteQueue @@ -90,6 +94,8 @@ import Wire.IndexedUserStore (IndexedUserStore) import Wire.IndexedUserStore qualified as IndexedUserStore import Wire.IndexedUserStore.Bulk.ElasticSearch (teamSearchVisibilityInbound) import Wire.InvitationStore +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem qualified as Mls import Wire.Sem.Concurrency import Wire.Sem.Metrics import Wire.Sem.Metrics qualified as Metrics @@ -112,6 +118,8 @@ import Witherable (wither) runUserSubsystem :: ( Member AppStore r, Member UserStore r, + Member ClientStore r, + Member MlsKeyPackageSubsystem r, Member UserKeyStore r, Member GalleyAPIAccess r, Member BlockListStore r, @@ -148,8 +156,8 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = getUserProfilesImpl self others GetLocalUserProfiles others -> getLocalUserProfilesImpl others - GetLocalAppProfiles ltid -> - getLocalAppProfilesOnlyImpl ltid + GetLocalAppProfiles self ltid -> + getLocalAppProfilesImpl self ltid GetAccountsBy getBy -> getAccountsByImpl getBy GetAccountsByEmailNoFilter emails -> @@ -182,6 +190,8 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = updateTeamSearchVisibilityInboundImpl status SearchUsers luid query mDomain mMaxResults mTypes -> searchUsersImpl luid query mDomain mMaxResults mTypes + IsUsersContactable users mlsAvailable allowedCipherSuites -> + isUsersContactableImpl users mlsAvailable allowedCipherSuites BrowseTeam uid browseTeamFilters mMaxResults mPagingState -> browseTeamImpl uid browseTeamFilters mMaxResults mPagingState InternalUpdateSearchIndex uid -> @@ -196,6 +206,54 @@ runUserSubsystem authInterpreter appInterpreter clientInterpreter = CheckUserIsAdmin uid -> checkUserIsAdminImpl uid UserSubsystem.SetUserSearchable luid uid searchability -> setUserSearchableImpl luid uid searchability +isUsersContactableImpl :: + forall r. + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => + Map UserId (Set BaseProtocolTag, Set UserClient.Client) -> + Bool -> + Set CipherSuiteTag -> + Sem r (Map UserId Bool) +isUsersContactableImpl users mlsAvailable allowedCipherSuites = do + let prekeyRequests = + [ (uid, client.clientId) + | (uid, (protocols, clients)) <- Map.toList users, + Set.member BaseProtocolProteusTag protocols, + client <- Set.toList clients + ] + mlsRequests = + [ (uid, client.clientId, ciphersuite) + | mlsAvailable, + (uid, (protocols, clients)) <- Map.toList users, + Set.member BaseProtocolMLSTag protocols, + client <- Set.toList clients, + ciphersuite <- Set.toList allowedCipherSuites, + Map.member + (csSignatureScheme ciphersuite) + client.clientMLSPublicKeys + ] + prekeyPresence <- ClientStore.lookupPrekeyPresenceBulk prekeyRequests + mlsPresence <- Mls.hasMlsKeyPackagesBulk mlsRequests + pure $ Map.mapWithKey (isContactable prekeyPresence mlsPresence) users + where + isContactable prekeyPresence mlsPresence uid (protocols, clients) = + let proteusReady = + Set.member BaseProtocolProteusTag protocols + && any (\client -> Set.member (uid, client.clientId) prekeyPresence) clients + mlsReady = + mlsAvailable + && Set.member BaseProtocolMLSTag protocols + && any + ( \client -> + any + ( \ciphersuite -> + Map.member (csSignatureScheme ciphersuite) client.clientMLSPublicKeys + && Set.member (uid, client.clientId, ciphersuite) mlsPresence + ) + allowedCipherSuites + ) + clients + in proteusReady || mlsReady + scimExtId :: StoredUser -> Maybe Text scimExtId su = do m <- su.managedBy @@ -366,22 +424,29 @@ getLocalUserProfilesImpl :: Sem r [UserProfile] getLocalUserProfilesImpl = getUserProfilesLocalPart Nothing -getLocalAppProfilesOnlyImpl :: +getLocalAppProfilesImpl :: forall r any. ( Member AppStore r, Member UserStore r, Member (Input UserSubsystemConfig) r, Member DeleteQueue r, + Member (Error UserSubsystemError) r, Member Now r, Member (Concurrency Unsafe) r, Member (Input (Local any)) r, Member AppSubsystem r, Member TeamSubsystem r ) => - Local TeamId -> + Local UserId -> + TeamId -> Sem r [UserProfile] -getLocalAppProfilesOnlyImpl ltid = do - apps <- AppStore.getApps (tUnqualified ltid) +getLocalAppProfilesImpl self tid = do + UserStore.getUserTeam (tUnqualified self) >>= \requestingUserTeam -> + unless (requestingUserTeam == Just tid) $ + throw UserSubsystemProfileNotFound + + let ltid = qualifyAs self tid + apps <- AppStore.getApps tid profiles <- getUserProfilesLocalPart Nothing (ltid $> map (.id) apps) let appsMap :: Map UserId AppStore.StoredApp appsMap = Map.fromList ((\app -> (app.id, app)) <$> apps) @@ -1141,6 +1206,8 @@ acceptTeamInvitationImpl luid pw code = do unless added $ throw UserSubsystemTooManyTeamMembers updateUserTeam uid tid deleteInvitation inv.teamId inv.invitationId + for_ (userEmail . selfUser =<< mSelfProfile) $ \email -> + deletePendingScimUser tid email uid syncUserIndex uid generateUserEvent uid Nothing (teamUpdated uid tid) diff --git a/services/spar/src/Spar/Sem/VerdictFormatStore.hs b/libs/wire-subsystems/src/Wire/VerdictFormatStore.hs similarity index 97% rename from services/spar/src/Spar/Sem/VerdictFormatStore.hs rename to libs/wire-subsystems/src/Wire/VerdictFormatStore.hs index df8770c77c3..1d8568126c4 100644 --- a/services/spar/src/Spar/Sem/VerdictFormatStore.hs +++ b/libs/wire-subsystems/src/Wire/VerdictFormatStore.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.VerdictFormatStore +module Wire.VerdictFormatStore ( VerdictFormatStore (..), store, get, diff --git a/services/spar/src/Spar/Sem/VerdictFormatStore/Cassandra.hs b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Cassandra.hs similarity index 65% rename from services/spar/src/Spar/Sem/VerdictFormatStore/Cassandra.hs rename to libs/wire-subsystems/src/Wire/VerdictFormatStore/Cassandra.hs index 5bf82d3cbea..315038b77e5 100644 --- a/services/spar/src/Spar/Sem/VerdictFormatStore/Cassandra.hs +++ b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Cassandra.hs @@ -1,3 +1,4 @@ +{-# OPTIONS_GHC -Wno-orphans #-} -- Disabling to stop warnings on HasCallStack {-# OPTIONS_GHC -Wno-redundant-constraints #-} @@ -18,7 +19,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.VerdictFormatStore.Cassandra +module Wire.VerdictFormatStore.Cassandra ( verdictFormatStoreToCassandra, ) where @@ -28,13 +29,41 @@ import Control.Lens import Data.Time import Imports import Polysemy -import Spar.Data -import Spar.Data.Instances (VerdictFormatCon, VerdictFormatRow, fromVerdictFormat, toVerdictFormat) -import Spar.Sem.VerdictFormatStore import URI.ByteString import Wire.API.User.Auth import Wire.API.User.Saml import Wire.IdPConfigStore.Orphans () +import Wire.VerdictFormatStore + +-- Duplicated from Spar.Data (Wire cannot depend on Spar); Spar.Data keeps its +-- own copy for mkTTLNDT. +nominalDiffToSeconds :: NominalDiffTime -> Int32 +nominalDiffToSeconds = round @Double . realToFrac + +type VerdictFormatRow = (VerdictFormatCon, Maybe URI, Maybe URI, Maybe CookieLabel) + +data VerdictFormatCon = VerdictFormatConWeb | VerdictFormatConMobile + +instance Cql VerdictFormatCon where + ctype = Tagged IntColumn + + toCql VerdictFormatConWeb = CqlInt 0 + toCql VerdictFormatConMobile = CqlInt 1 + + fromCql (CqlInt i) = case i of + 0 -> pure VerdictFormatConWeb + 1 -> pure VerdictFormatConMobile + n -> Left $ "unexpected VerdictFormatCon: " ++ show n + fromCql _ = Left "member-status: int expected" + +fromVerdictFormat :: VerdictFormat -> VerdictFormatRow +fromVerdictFormat (VerdictFormatWeb mlabel) = (VerdictFormatConWeb, Nothing, Nothing, mlabel) +fromVerdictFormat (VerdictFormatMobile succredir errredir mlabel) = (VerdictFormatConMobile, Just succredir, Just errredir, mlabel) + +toVerdictFormat :: VerdictFormatRow -> Maybe VerdictFormat +toVerdictFormat (VerdictFormatConWeb, Nothing, Nothing, mlabel) = Just $ VerdictFormatWeb mlabel +toVerdictFormat (VerdictFormatConMobile, Just succredir, Just errredir, mlabel) = Just $ VerdictFormatMobile succredir errredir mlabel +toVerdictFormat _ = Nothing verdictFormatStoreToCassandra :: forall m r a. diff --git a/services/spar/src/Spar/Sem/VerdictFormatStore/Mem.hs b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Mem.hs similarity index 89% rename from services/spar/src/Spar/Sem/VerdictFormatStore/Mem.hs rename to libs/wire-subsystems/src/Wire/VerdictFormatStore/Mem.hs index 12ecf2368ad..11588feefcb 100644 --- a/services/spar/src/Spar/Sem/VerdictFormatStore/Mem.hs +++ b/libs/wire-subsystems/src/Wire/VerdictFormatStore/Mem.hs @@ -17,21 +17,21 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Spar.Sem.VerdictFormatStore.Mem +module Wire.VerdictFormatStore.Mem ( verdictFormatStoreToMem, ) where -import qualified Data.Map as M +import Data.Map qualified as M import Imports import Polysemy import Polysemy.State hiding (Get) import SAML2.WebSSO (addTime) -import qualified SAML2.WebSSO.Types as SAML -import Spar.Sem.VerdictFormatStore +import SAML2.WebSSO.Types qualified as SAML import Wire.API.User.Saml (AReqId, VerdictFormat) import Wire.Sem.Now (Now, boolTTL) -import qualified Wire.Sem.Now as Now +import Wire.Sem.Now qualified as Now +import Wire.VerdictFormatStore verdictFormatStoreToMem :: (Member Now r) => diff --git a/libs/wire-subsystems/template-version b/libs/wire-subsystems/template-version index e28f3a1b1bb..a21d12cfba1 100644 --- a/libs/wire-subsystems/template-version +++ b/libs/wire-subsystems/template-version @@ -1 +1 @@ -v1.0.148 +v1.0.155 diff --git a/libs/wire-subsystems/templates/de/team/email/app-availability-change.html b/libs/wire-subsystems/templates/de/team/email/app-availability-change.html new file mode 100644 index 00000000000..2f71397558f --- /dev/null +++ b/libs/wire-subsystems/templates/de/team/email/app-availability-change.html @@ -0,0 +1 @@ +

${brand_label_url}

Änderung der Verfügbarkeit einer App

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

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

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

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

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

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

${brand_label_url}

Neue App erstellt

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

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

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

Berechtigungen:
${permissions}

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

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

${brand_label_url}

App gelöscht

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

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

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

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

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

${brand_label_url}

Details für eine App geändert

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

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

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

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

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

${brand_label_url}

Aktualisierter App-Authentifizierungstoken

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

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

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

Wenn Sie Fragen haben, dann kontaktieren Sie uns bitte.

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

${brand_label_url}

Änderung in der Konfiguration Ihres Identity Providers

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

Team-ID:
${team_id}

Benutzer-ID:
${user_id}


Details:

IdP-ID:
${idp_id}

Neuer IdP-Aussteller:
${new_idp_issuer}

Neuer IdP-Endpunkt:
${new_idp_endpoint}

Alter IdP-Aussteller:
${old_idp_issuer}

Alter IdP-Endpunkt:
${old_idp_endpoint}


${certificates_details}

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

 

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

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

${brand_label_url}

Änderung in der Konfiguration Ihres Identity Providers

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

Team ID:
${team_id}

Benutzer-ID:
${user_id}


Details:

IdP ID:
${idp_id}

Neuer IdP-Aussteller:
${new_idp_issuer}

Neuer IdP-Endpunkt:
${new_idp_endpoint}

Alter IdP-Aussteller:
${old_idp_issuer}

Alter IdP-Endpunkt:
${old_idp_endpoint}


${certificates_details}

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

 

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

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

${brand_label_url}

Availability change for an app

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

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

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

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

If you have any questions, please contact us.

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

${brand_label_url}

New app created

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

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

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

Permissions: ${permissions}

If you have any questions, please contact us.

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

${brand_label_url}

App deleted

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

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

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

If you have any questions, please contact us.

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

${brand_label_url}

Details changed for an app

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

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

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

If you have any questions, please contact us.

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

${brand_label_url}

Updated app authentication token

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

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

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

If you have any questions, please contact us.

                                                           
\ No newline at end of file diff --git a/libs/wire-subsystems/templates/en/team/email/app-token-change.txt b/libs/wire-subsystems/templates/en/team/email/app-token-change.txt new file mode 100644 index 00000000000..39af4c6383a --- /dev/null +++ b/libs/wire-subsystems/templates/en/team/email/app-token-change.txt @@ -0,0 +1,20 @@ +[${brand_logo}] + +${brand_label_url} [${brand_url}] + +UPDATED APP AUTHENTICATION TOKEN +${actor} has updated the authentication token of an app from your team. + +App name: ${app_name} +Date: ${date} + +Team name: ${team_name} +Team ID: ${team_id} + +Open Team Management [${url}]If you have any questions, please contact us [${support}]. + + +-------------------------------------------------------------------------------- + +Privacy policy and terms of use [${legal}] · Report Misuse [${misuse}] +${copyright}. ALL RIGHTS RESERVED. \ No newline at end of file diff --git a/libs/wire-subsystems/templates/version b/libs/wire-subsystems/templates/version index e28f3a1b1bb..a21d12cfba1 100644 --- a/libs/wire-subsystems/templates/version +++ b/libs/wire-subsystems/templates/version @@ -1 +1 @@ -v1.0.148 +v1.0.155 diff --git a/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt b/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt index 8ef7bc4d070..3ba820e07b0 100644 --- a/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt +++ b/libs/wire-subsystems/test/resources/mails/idp-config-change_created_de.txt @@ -5,7 +5,7 @@ wire.example.com [https://wire.example.com] ÄNDERUNG IN DER KONFIGURATION IHRES IDENTITY PROVIDERS Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert. -Team-ID: +Team ID: 99f552d8-9dad-60c1-4be9-c88fb532893a Benutzer-ID: @@ -16,7 +16,7 @@ Benutzer-ID: Details: -IdP-ID: +IdP ID: 574ddfb0-4e50-2bff-e924-33ee2b9f7064 Neuer IdP-Aussteller: diff --git a/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt b/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt index d3313df47af..570fc5667fe 100644 --- a/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt +++ b/libs/wire-subsystems/test/resources/mails/idp-config-change_deleted_de.txt @@ -5,7 +5,7 @@ wire.example.com [https://wire.example.com] ÄNDERUNG IN DER KONFIGURATION IHRES IDENTITY PROVIDERS Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert. -Team-ID: +Team ID: 99f552d8-9dad-60c1-4be9-c88fb532893a Benutzer-ID: @@ -16,7 +16,7 @@ Benutzer-ID: Details: -IdP-ID: +IdP ID: 574ddfb0-4e50-2bff-e924-33ee2b9f7064 Neuer IdP-Aussteller: diff --git a/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt b/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt index e7aa88108cf..d4f4a7a295f 100644 --- a/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt +++ b/libs/wire-subsystems/test/resources/mails/idp-config-change_updated_de.txt @@ -5,7 +5,7 @@ wire.example.com [https://wire.example.com] ÄNDERUNG IN DER KONFIGURATION IHRES IDENTITY PROVIDERS Etwas hat sich in der IdP-Konfiguration für Ihr Team geändert. -Team-ID: +Team ID: 99f552d8-9dad-60c1-4be9-c88fb532893a Benutzer-ID: @@ -16,7 +16,7 @@ Benutzer-ID: Details: -IdP-ID: +IdP ID: 574ddfb0-4e50-2bff-e924-33ee2b9f7064 Neuer IdP-Aussteller: diff --git a/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs index 769dc9d82bf..43493684895 100644 --- a/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ClientSubsystem/InterpreterSpec.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.ClientSubsystem.InterpreterSpec (spec) where import Data.Aeson qualified as A diff --git a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs index 34133bf4475..2affa1d807f 100644 --- a/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/ConversationSubsystem/InterpreterSpec.hs @@ -17,40 +17,48 @@ module Wire.ConversationSubsystem.InterpreterSpec (spec) where +import Data.Aeson qualified as A import Data.Default (def) import Data.Domain (Domain (..)) import Data.Id import Data.Map.Strict qualified as Map import Data.Qualified +import Data.Set qualified as Set import Data.Tagged (Tagged) import Data.UUID qualified as UUID import Imports import Polysemy import Polysemy.Error import Polysemy.Input +import Polysemy.State import Test.Hspec import Test.Hspec.QuickCheck (prop) -import Test.QuickCheck (Arbitrary (..), Gen, arbitrary, chooseInt, counterexample, generate, ioProperty, vectorOf, (===)) +import Test.QuickCheck (Arbitrary (..), Gen, Property, arbitrary, chooseInt, conjoin, counterexample, generate, ioProperty, vectorOf, (===)) import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Config (ConversationSubsystemConfig (..)) import Wire.API.Conversation.Protocol (ConversationMLSData (..), Protocol (..)) import Wire.API.Conversation.Role hiding (DeleteConversation) import Wire.API.Error.Galley (AdminlessConversation (..), GalleyError (..)) +import Wire.API.Event.Conversation (Event (..), EventData (..), MemberUpdateData (..)) import Wire.API.Federation.Client (FederatorClient) import Wire.API.Federation.Error (FederationError) -import Wire.API.Team.Feature (AllTeamFeatures, FeatureStatus (..), LockStatus (..), LockableFeature (..), PreventAdminlessGroupsConfig, npProject, npUpdate) -import Wire.API.User (AccountStatus (..), User (..), UserType (..), userId) +import Wire.API.Team.Feature +import Wire.API.User import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess (..)) import Wire.BrigAPIAccess (BrigAPIAccess (..)) import Wire.ConversationStore (ConversationStore (..)) import Wire.ConversationSubsystem (RemoveMemberResponseMode (..)) -import Wire.ConversationSubsystem.Update (removeMemberQualified) +import Wire.ConversationSubsystem.Update (guardPreventAdminlessGroupsFor, removeMemberQualified) import Wire.ExternalAccess (ExternalAccess (..)) import Wire.FeaturesConfigSubsystem (FeaturesConfigSubsystem (..)) import Wire.FederationAPIAccess (FederationAPIAccess (..)) +import Wire.JobSubsystem (JobSubsystem (..)) +import Wire.MockInterpreters.ConversationStore (inMemoryConversationStoreInterpreter) +import Wire.MockInterpreters.NotificationSubsystem (inMemoryNotificationSubsystemInterpreter) import Wire.MockInterpreters.Now (defaultTime, interpretNowConst) +import Wire.MockInterpreters.Random (runRandomPure) import Wire.MockInterpreters.TinyLog (noopLogger) -import Wire.NotificationSubsystem (NotificationSubsystem (..)) +import Wire.NotificationSubsystem (NotificationSubsystem (..), Push (..)) import Wire.ProposalStore (ProposalStore (..)) import Wire.Sem.Random (Random (..)) import Wire.StoredConversation @@ -58,6 +66,253 @@ import Wire.TeamSubsystem (TeamSubsystem (..)) spec :: Spec spec = describe "ConversationSubsystem.Interpreter" do + prop "guardPreventAdminlessGroupsFor promotes an eligible member and emits a targeted update [legacy]" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let expectedTarget = head fx.fixtureEligibleMembers + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ updatedTargets === [expectedTarget], + scheduledJobs === [], + assertMemberUpdatePush expectedTarget pushes + ] + + prop "guardPreventAdminlessGroupsFor promotes all eligible members [legacy]" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let features = + npUpdate @PreventAdminlessGroupsConfig + ( LockableFeature + FeatureStatusEnabled + LockStatusUnlocked + ((def :: PreventAdminlessGroupsConfig) {promotionStrategy = PromotionStrategyAll}) + ) + def + expectedTargets = fx.fixtureEligibleMembers + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers features fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ Set.fromList updatedTargets === Set.fromList expectedTargets, + scheduledJobs === [], + assertMemberUpdatePushes expectedTargets pushes + ] + + prop "guardPreventAdminlessGroupsFor reports eligible members for the V17 response" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let expectedEligible = fx.fixtureEligibleMembers + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberEligibleMembersResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> + conjoin + [ err === AdminlessConversation {eligibleMembers = expectedEligible}, + updatedTargets === [], + scheduledJobs === [], + length pushes === 0 + ] + Right _ -> counterexample "expected adminless-conversation error" False + + prop "guardPreventAdminlessGroupsFor includes newly added eligible members in the V17 response" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let newlyAdded = head fx.fixtureEligibleMembers + conversations = + Map.adjust + (\conv -> conv {localMembers = [newMemberWithRole (leaving, roleNameWireAdmin)]}) + convId + fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberEligibleMembersResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + (Set.singleton newlyAdded) + pure $ + case result of + Left err -> + conjoin + [ err === AdminlessConversation {eligibleMembers = [newlyAdded]}, + updatedTargets === [], + scheduledJobs === [], + length pushes === 0 + ] + Right _ -> counterexample "expected adminless-conversation error" False + + prop "guardPreventAdminlessGroupsFor schedules deletion when no eligible members exist" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let users = + [ user {userType = UserTypeApp} + | user <- fx.fixtureUsers + ] + features = + npUpdate @PreventAdminlessGroupsConfig + ( LockableFeature + FeatureStatusEnabled + LockStatusUnlocked + ((def :: PreventAdminlessGroupsConfig) {reminderTimeouts = []}) + ) + def + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest users features fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ updatedTargets === [], + length pushes === 0, + scheduledJobs === [ScheduledAdminlessDeletion] + ] + + prop "guardPreventAdminlessGroupsFor does nothing when the feature is disabled" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let features = + npUpdate @PreventAdminlessGroupsConfig + (LockableFeature FeatureStatusDisabled LockStatusUnlocked def) + def + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers features fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor does nothing when another local admin remains" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let conversations = + Map.adjust + ( \conv -> + conv + { localMembers = + [ newMemberWithRole (leaving, roleNameWireAdmin), + newMemberWithRole (eligible1, roleNameWireAdmin), + newMemberWithRole (eligible2, roleNameWireMember) + ] + } + ) + convId + fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor does nothing when a remote admin remains" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let remoteAdmin = + RemoteMember + { id_ = toRemoteUnsafe domain eligible1, + convRoleName = roleNameWireAdmin + } + conversations = + Map.adjust (withRemoteMembers [remoteAdmin]) convId fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor does nothing when an admin is being added" $ + \domain teamId convId leaving eligible1 eligible2 -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures fx.fixtureConversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + (Set.singleton (head fx.fixtureEligibleMembers)) + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + + prop "guardPreventAdminlessGroupsFor ignores non-regular and channel conversations" $ + \domain teamId convId leaving eligible1 eligible2 outOfScopeChannel -> + ioProperty $ do + fx <- mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 + let conversations = + Map.adjust (withOutOfScopeMetadata outOfScopeChannel) convId fx.fixtureConversations + (pushes, (updatedTargets, (scheduledJobs, result))) = + runAdminlessGroupsTest fx.fixtureUsers fx.fixtureFeatures conversations $ + guardPreventAdminlessGroupsFor + RemoveMemberLegacyResponse + fx.fixtureLocalConversation + fx.fixtureLocalUser + (Set.singleton fx.fixtureVictim) + Set.empty + Set.empty + pure $ assertNoAdminlessAction pushes updatedTargets scheduledJobs result + prop "removeMemberQualified returns adminless-conversation error" $ \convDomain teamId @@ -97,6 +352,7 @@ spec = describe "ConversationSubsystem.Interpreter" do ] result = run + . runState @[ScheduledAdminlessJob] [] . runError @AdminlessConversation . runError @(Tagged ('ActionDenied 'RemoveConversationMember) ()) . runError @(Tagged ('ActionDenied 'ModifyOtherConversationMember) ()) @@ -121,16 +377,79 @@ spec = describe "ConversationSubsystem.Interpreter" do . interpretNotificationSubsystem . interpretProposalStore . interpretTeamSubsystem + . interpretJobSubsystem . interpretNowConst defaultTime . interpretRandom . noopLogger $ removeMemberQualified RemoveMemberEligibleMembersResponse lusr connId qcnv qvictim pure $ - case result of + case snd result of Left err -> err === AdminlessConversation {eligibleMembers = expectedEligible} Right _ -> counterexample ("expected adminless-conversation, got " <> show result) False + where + runAdminlessGroupsTest users features conversations testCode = + run + . runState @[Push] [] + . runState @[Qualified UserId] [] + . runState @[ScheduledAdminlessJob] [] + . runError @AdminlessConversation + . runError @(Tagged ('ActionDenied 'ModifyOtherConversationMember) ()) + . runError @(Tagged 'ConvMemberNotFound ()) + . runError @(Tagged 'ConvNotFound ()) + . runError @(Tagged 'InvalidOperation ()) + . runError @FederationError + . inMemoryConversationStoreInterpreter conversations + . interpretBrig users + . interpretFeatures features + . interpretBackendNotificationQueueAccess + . interpretFederation + . interpretExternalAccess + . inMemoryNotificationSubsystemInterpreter + . interpretTeamSubsystem + . interpretJobSubsystem + . interpretNowConst defaultTime + . runRandomPure + . noopLogger + $ testCode + +data AdminlessGroupsFixture = AdminlessGroupsFixture + { fixtureUsers :: [User], + fixtureLocalUser :: Local UserId, + fixtureLocalConversation :: Local ConvId, + fixtureVictim :: Qualified UserId, + fixtureEligibleMembers :: [Qualified UserId], + fixtureConversations :: Map.Map ConvId StoredConversation, + fixtureFeatures :: AllTeamFeatures + } + +mkAdminlessGroupsFixture :: Domain -> TeamId -> ConvId -> UserId -> UserId -> UserId -> IO AdminlessGroupsFixture +mkAdminlessGroupsFixture domain teamId convId leaving eligible1 eligible2 = do + user1 <- mkUserWithName "Alice" domain UserTypeRegular eligible1 + user2 <- mkUserWithName "Bob" domain UserTypeRegular eligible2 + let conv = + StoredConversation + { id_ = convId, + localMembers = + [ newMemberWithRole (leaving, roleNameWireAdmin), + newMemberWithRole (eligible1, roleNameWireMember), + newMemberWithRole (eligible2, roleNameWireMember) + ], + remoteMembers = [], + metadata = (defConversationMetadata (Just leaving)) {cnvmTeam = Just teamId}, + protocol = ProtocolMLS (ConversationMLSData (GroupId "mock-group-id") Nothing) + } + pure + AdminlessGroupsFixture + { fixtureUsers = [user1, user2], + fixtureLocalUser = toLocalUnsafe domain leaving, + fixtureLocalConversation = toLocalUnsafe domain convId, + fixtureVictim = Qualified leaving domain, + fixtureEligibleMembers = [Qualified eligible1 domain, Qualified eligible2 domain], + fixtureConversations = Map.singleton convId conv, + fixtureFeatures = npUpdate @PreventAdminlessGroupsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) def + } data MemberInputs = MemberInputs { localUserIds :: [UserId], @@ -166,6 +485,59 @@ instance Arbitrary MemberInputs where ] } +assertMemberUpdatePush :: Qualified UserId -> [Push] -> Property +assertMemberUpdatePush expectedTarget = assertMemberUpdatePushes [expectedTarget] + +assertMemberUpdatePushes :: [Qualified UserId] -> [Push] -> Property +assertMemberUpdatePushes expectedTargets pushes = + case traverse decodeMemberUpdate pushes of + Left err -> counterexample err False + Right updates -> + conjoin + [ length pushes === length expectedTargets, + Set.fromList (map (.misTarget) updates) === Set.fromList expectedTargets, + conjoin [update.misConvRoleName === Just roleNameWireAdmin | update <- updates] + ] + where + decodeMemberUpdate push = + case A.fromJSON @(Event) (A.Object push.json) of + A.Success Event {evtData = EdMemberUpdate update} -> Right update + A.Success event -> Left ("unexpected event: " <> show event) + A.Error err -> Left ("failed to decode push: " <> err) + +assertNoAdminlessAction :: + [Push] -> + [Qualified UserId] -> + [ScheduledAdminlessJob] -> + Either AdminlessConversation a -> + Property +assertNoAdminlessAction pushes updatedTargets scheduledJobs result = + case result of + Left err -> counterexample ("unexpected adminless error: " <> show err) False + Right _ -> + conjoin + [ updatedTargets === [], + length pushes === 0, + scheduledJobs === [] + ] + +withRemoteMembers :: [RemoteMember] -> StoredConversation -> StoredConversation +withRemoteMembers members (StoredConversation convId localMembers _ metadata protocol) = + StoredConversation convId localMembers members metadata protocol + +withOutOfScopeMetadata :: Bool -> StoredConversation -> StoredConversation +withOutOfScopeMetadata outOfScopeChannel (StoredConversation convId localMembers remoteMembers metadata protocol) = + StoredConversation + convId + localMembers + remoteMembers + ( metadata + { cnvmType = if outOfScopeChannel then RegularConv else One2OneConv, + cnvmGroupConvType = if outOfScopeChannel then Just Channel else Just GroupConversation + } + ) + protocol + -- Build one lazy infinite pool of distinct IDs and slice it into categories. -- Using position in the stream, rather than per-category prefixes, makes the -- disjointness guarantee obvious in the test data itself. @@ -240,6 +612,8 @@ interpretBackendNotificationQueueAccess :: Sem r a interpretBackendNotificationQueueAccess = interpret $ \case + EnqueueNotificationsConcurrently _ remotes _ -> + if null remotes then pure (Right []) else error "unexpected remote notification in guard test" _ -> error "unexpected BackendNotificationQueueAccess call in test" interpretProposalStore :: @@ -258,8 +632,29 @@ interpretTeamSubsystem :: Sem r a interpretTeamSubsystem = interpret $ \case + InternalGetTeamMember _ _ -> pure Nothing _ -> error "unexpected TeamSubsystem call in test" +data ScheduledAdminlessJob + = ScheduledAdminlessDeletion + | ScheduledAdminlessReminder + deriving stock (Eq, Show) + +interpretJobSubsystem :: + (Member (State [ScheduledAdminlessJob]) r) => + Sem (JobSubsystem ': r) a -> + Sem r a +interpretJobSubsystem = + interpret $ \case + ScheduleAdminlessSetupJob {} -> + pure () + ScheduleAdminlessDeletionJob {} -> + modify @[ScheduledAdminlessJob] (<> [ScheduledAdminlessDeletion]) + ScheduleAdminlessReminderJob {} -> + modify @[ScheduledAdminlessJob] (<> [ScheduledAdminlessReminder]) + CancelAdminlessJobsForTeam {} -> + pure () + interpretRandom :: Sem (Random ': r) a -> Sem r a @@ -285,3 +680,15 @@ mkUser domain utype uid = do userStatus = Active, userService = Nothing } + +mkUserWithName :: Text -> Domain -> UserType -> UserId -> IO User +mkUserWithName name domain utype uid = do + base <- generate arbitrary + pure + base + { userQualifiedId = Qualified uid domain, + userType = utype, + userStatus = Active, + userService = Nothing, + userDisplayName = Name name + } diff --git a/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateFixtures.hs b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateFixtures.hs new file mode 100644 index 00000000000..b6d744ef82c --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateFixtures.hs @@ -0,0 +1,79 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- | Shared fixtures for tests that render email templates from the on-disk +-- templates shipped with this package. The URL templates and branding here +-- mirror the shape of the corresponding Brig configuration. +module Wire.EmailSubsystem.TemplateFixtures where + +import Data.Map qualified as Map +import Imports +import Text.Email.Parser (unsafeEmailAddress) +import Wire.API.Locale +import Wire.API.User.EmailAddress (EmailAddress) +import Wire.EmailSubsystem.Template +import Wire.EmailSubsystem.Templates.Team +import Wire.EmailSubsystem.Templates.User + +teamOpts :: TeamOpts +teamOpts = + TeamOpts + { tInvitationUrl = "https://example.com/join/?team-code=${code}", + tExistingUserInvitationUrl = "https://example.com/accept-invitation/?team-code=${code}", + tActivationUrl = "https://example.com/verify/?key=${key}&code=${code}", + tCreatorWelcomeUrl = "https://example.com/creator-welcome-website", + tMemberWelcomeUrl = "https://example.com/member-welcome-website" + } + +userTemplateOpts :: UserTemplateOpts +userTemplateOpts = + UserTemplateOpts + { activationUrl = "https://example.com/verify/?key=${key}&code=${code}", + teamActivationUrl = teamOpts.tActivationUrl, + passwordResetUrl = "https://example.com/reset/?key=${key}&code=${code}", + deletionUrl = "https://example.com/d/?key=${key}&code=${code}" + } + +defLocale :: Locale +defLocale = Locale ((fromJust . parseLanguage) "en") Nothing + +emailSender :: EmailAddress +emailSender = unsafeEmailAddress "wire" "example.com" + +branding :: Map Text Text +branding = + Map.fromList + [ ("brand", "Wire Test"), + ("brand_url", "https://wire.example.com"), + ("brand_label_url", "wire.example.com"), + ("brand_logo", "https://wire.example.com/p/img/email/logo-email-black.png"), + ("brand_service", "Wire Service Provider"), + ("copyright", "© WIRE SWISS GmbH"), + ("misuse", "misuse@wire.example.com"), + ("legal", "https://wire.example.com/legal/"), + ("forgot", "https://wire.example.com/forgot/"), + ("support", "https://support.wire.com/") + ] + +-- | Load the on-disk team templates. Relies on the test suite running with the +-- package directory as its working directory (as @cabal test@ does). +loadTestTeamTemplates :: IO (Localised TeamTemplates) +loadTestTeamTemplates = loadTeamTemplates teamOpts "templates" defLocale emailSender + +-- | Load the on-disk user templates. See 'loadTestTeamTemplates'. +loadTestUserTemplates :: IO (Localised UserTemplates) +loadTestUserTemplates = loadUserTemplates userTemplateOpts "templates" defLocale emailSender diff --git a/services/brig/test/integration/API/Template.hs b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateSpec.hs similarity index 57% rename from services/brig/test/integration/API/Template.hs rename to libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateSpec.hs index 4c697b88ed5..d19313e9d69 100644 --- a/services/brig/test/integration/API/Template.hs +++ b/libs/wire-subsystems/test/unit/Wire/EmailSubsystem/TemplateSpec.hs @@ -1,10 +1,22 @@ -module API.Template (tests) where +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2025 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.EmailSubsystem.TemplateSpec (spec) where -import Bilge -import Brig.Options -import Brig.Team.Template (loadTeamTemplatesWithBrigOpts) -import Brig.Template -import Brig.User.Template (loadUserTemplates) import Data.Code import Data.Id import Data.Json.Util @@ -18,9 +30,7 @@ import Imports import Network.Mail.Mime import Polysemy import Polysemy.Output -import Test.Tasty -import Test.Tasty.HUnit -import Util +import Test.Hspec import Wire.API.Locale import Wire.API.User (InvitationCode (InvitationCode, fromInvitationCode)) import Wire.API.User.Activation @@ -30,57 +40,43 @@ import Wire.API.User.Password import Wire.API.User.Profile import Wire.EmailSubsystem.Interpreter import Wire.EmailSubsystem.Template +import Wire.EmailSubsystem.TemplateFixtures import Wire.EmailSubsystem.Templates.Team import Wire.EmailSubsystem.Templates.User --- FUTUREWORK: This does not have to be an integration test. It could be --- covered by unit tests in wire-subsystems. Then, the helper functions can be --- privatized. -tests :: Opts -> Manager -> IO TestTree -tests opts m = do - team <- liftIO $ loadTeamTemplatesWithBrigOpts opts - user <- liftIO $ loadUserTemplates opts - let teamTemplates = Map.assocs $ uncurry Map.insert team.locDefault team.locOther - userTemplates = Map.assocs $ uncurry Map.insert user.locDefault user.locOther - b = genTemplateBrandingMap opts.emailSMS.general.templateBranding - pure $ - testGroup - "email templates" - [ testGroup - "team" - $ fmap - ( \(loc, ts) -> - testGroup - (show loc) - [ test m "team invitation" $ testTeamInvitationEmail b ts, - test m "team invitation existing user" $ testTeamInvitationEmailExistingUser b ts, - test m "member welcome" $ testMemberWelcomeEmail b ts, - test m "new team owner welcome" $ testNewTeamOwnerWelcomeEmail b ts - ] - ) - teamTemplates, - testGroup "user" $ - fmap - ( \(loc, ts) -> - testGroup - (show loc) - [ test m "password reset email" $ testPasswordResetEmail b ts, - test m "verification email" $ testVerificationEmail b ts, - test m "team deletion verification email" $ testTeamDeletionVerificationEmail b ts, - test m "scim token verification email" $ testScimTokenVerificationEmail b ts, - test m "login verification email" $ testLoginVerificationEmail b ts, - test m "new client email" $ testNewClientEmail b loc ts, - test m "account deletion email" $ testAccountDeletionEmail b ts, - test m "activation email" $ testActivationEmail b ts, - test m "activation email update" $ testActivationEmailUpdate b ts, - test m "team activation email" $ testTeamActivationEmail b ts - ] - ) - userTemplates - ] - -testTeamInvitationEmailExistingUser :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http () -testTeamInvitationEmailExistingUser branding templates = do +-- | Insert the default locale into the map of other locales, giving the full +-- set of locales that ship templates. Each is exercised below. +byLocale :: Localised a -> [(Locale, a)] +byLocale l = Map.assocs $ uncurry Map.insert l.locDefault l.locOther + +spec :: Spec +spec = do + teamTemplates <- runIO loadTestTeamTemplates + userTemplates <- runIO loadTestUserTemplates + describe "email templates" $ do + describe "team" $ + for_ (byLocale teamTemplates) $ \(loc, ts) -> + describe (show loc) $ do + it "team invitation" $ testTeamInvitationEmail ts + it "team invitation existing user" $ testTeamInvitationEmailExistingUser ts + it "member welcome" $ testMemberWelcomeEmail ts + it "new team owner welcome" $ testNewTeamOwnerWelcomeEmail ts + describe "user" $ + for_ (byLocale userTemplates) $ \(loc, ts) -> + describe (show loc) $ do + it "password reset email" $ testPasswordResetEmail ts + it "verification email" $ testVerificationEmail ts + it "team deletion verification email" $ testTeamDeletionVerificationEmail ts + it "scim token verification email" $ testScimTokenVerificationEmail ts + it "login verification email" $ testLoginVerificationEmail ts + it "new client email" $ testNewClientEmail loc ts + it "account deletion email" $ testAccountDeletionEmail ts + it "activation email" $ testActivationEmail ts + it "activation email update" $ testActivationEmailUpdate ts + it "team activation email" $ testTeamActivationEmail ts + +testTeamInvitationEmailExistingUser :: (HasCallStack) => TeamTemplates -> Expectation +testTeamInvitationEmailExistingUser templates = do let tpl = templates.existingUserInvitationEmail (errs, (mail, url)) = run $ runOutputList @Text $ renderInvitationEmail input tpl branding input = @@ -90,12 +86,12 @@ testTeamInvitationEmailExistingUser branding templates = do invInvCode = InvitationCode {fromInvitationCode = fromRight undefined (validate "ZoMX0xs=")}, invInviter = fromJust $ emailAddressText "inviter@example.com" } - liftIO $ mail.mailFrom.addressEmail @?= (fromEmail tpl.invitationEmailSender) - liftIO $ url @?= "https://example.com/accept-invitation/?team-code=ZoMX0xs=" + mail.mailFrom.addressEmail `shouldBe` fromEmail tpl.invitationEmailSender + url `shouldBe` "https://example.com/accept-invitation/?team-code=ZoMX0xs=" assertNoErrors errs -testTeamInvitationEmail :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http () -testTeamInvitationEmail branding templates = do +testTeamInvitationEmail :: (HasCallStack) => TeamTemplates -> Expectation +testTeamInvitationEmail templates = do let tpl = templates.invitationEmail (errs, (mail, url)) = run $ runOutputList @Text $ renderInvitationEmail input tpl branding input = @@ -105,12 +101,12 @@ testTeamInvitationEmail branding templates = do invInvCode = InvitationCode {fromInvitationCode = fromRight undefined (validate "ZoMX0xs=")}, invInviter = fromJust $ emailAddressText "inviter@example.com" } - liftIO $ mail.mailFrom.addressEmail @?= (fromEmail tpl.invitationEmailSender) - liftIO $ url @?= "https://example.com/join/?team-code=ZoMX0xs=" + mail.mailFrom.addressEmail `shouldBe` fromEmail tpl.invitationEmailSender + url `shouldBe` "https://example.com/join/?team-code=ZoMX0xs=" assertNoErrors errs -testMemberWelcomeEmail :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http () -testMemberWelcomeEmail branding templates = do +testMemberWelcomeEmail :: (HasCallStack) => TeamTemplates -> Expectation +testMemberWelcomeEmail templates = do let tpl = templates.memberWelcomeEmail to = fromJust $ emailAddressText "test@example.com" tid = Id (fromJust $ UUID.fromString "123e4567-e89b-12d3-a456-426614174000") @@ -118,8 +114,8 @@ testMemberWelcomeEmail branding templates = do (errs, _) = run $ runOutputList @Text $ renderMemberWelcomeMail to tid tname tpl branding assertNoErrors errs -testNewTeamOwnerWelcomeEmail :: (HasCallStack) => Map Text Text -> TeamTemplates -> Http () -testNewTeamOwnerWelcomeEmail branding templates = do +testNewTeamOwnerWelcomeEmail :: (HasCallStack) => TeamTemplates -> Expectation +testNewTeamOwnerWelcomeEmail templates = do let tpl = templates.newTeamOwnerWelcomeEmail to = fromJust $ emailAddressText "test@example.com" tid = Id (fromJust $ UUID.fromString "123e4567-e89b-12d3-a456-426614174000") @@ -128,8 +124,8 @@ testNewTeamOwnerWelcomeEmail branding templates = do (errs, _) = run $ runOutputList @Text $ renderNewTeamOwnerWelcomeEmail to tid tname name tpl branding assertNoErrors errs -testPasswordResetEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testPasswordResetEmail branding templates = do +testPasswordResetEmail :: (HasCallStack) => UserTemplates -> Expectation +testPasswordResetEmail templates = do let tpl = templates.passwordResetEmail to = fromJust $ emailAddressText "test@example.com" key = mkPasswordResetKey (Id UUID.nil) @@ -137,8 +133,8 @@ testPasswordResetEmail branding templates = do (errs, _) = run $ runOutputList @Text $ renderPwResetMail to key code tpl branding assertNoErrors errs -testVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testVerificationEmail branding templates = do +testVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation +testVerificationEmail templates = do let tpl = templates.verificationEmail to = fromJust $ emailAddressText "test@example.com" key = ActivationKey . Ascii.unsafeFromText $ "key" @@ -146,32 +142,32 @@ testVerificationEmail branding templates = do (errs, _) = run $ runOutputList @Text $ renderVerificationMail to key code tpl branding assertNoErrors errs -testTeamDeletionVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testTeamDeletionVerificationEmail branding templates = do +testTeamDeletionVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation +testTeamDeletionVerificationEmail templates = do let tpl = templates.verificationTeamDeletionEmail to = fromJust $ emailAddressText "test@example.com" code = Value . unsafeRange . Ascii.unsafeFromText $ "code" (errs, _) = run $ runOutputList @Text $ renderSecondFactorVerificationEmail to code tpl branding assertNoErrors errs -testScimTokenVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testScimTokenVerificationEmail branding templates = do +testScimTokenVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation +testScimTokenVerificationEmail templates = do let tpl = templates.verificationScimTokenEmail to = fromJust $ emailAddressText "test@example.com" code = Value . unsafeRange . Ascii.unsafeFromText $ "code" (errs, _) = run $ runOutputList @Text $ renderSecondFactorVerificationEmail to code tpl branding assertNoErrors errs -testLoginVerificationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testLoginVerificationEmail branding templates = do +testLoginVerificationEmail :: (HasCallStack) => UserTemplates -> Expectation +testLoginVerificationEmail templates = do let tpl = templates.verificationLoginEmail to = fromJust $ emailAddressText "test@example.com" code = Value . unsafeRange . Ascii.unsafeFromText $ "code" (errs, _) = run $ runOutputList @Text $ renderSecondFactorVerificationEmail to code tpl branding assertNoErrors errs -testActivationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testActivationEmail branding templates = do +testActivationEmail :: (HasCallStack) => UserTemplates -> Expectation +testActivationEmail templates = do let tpl = templates.activationEmail to = fromJust $ emailAddressText "test@example.com" name = Name "name" @@ -180,8 +176,8 @@ testActivationEmail branding templates = do (errs, _) = run $ runOutputList @Text $ renderActivationMail to name key code tpl branding assertNoErrors errs -testActivationEmailUpdate :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testActivationEmailUpdate branding templates = do +testActivationEmailUpdate :: (HasCallStack) => UserTemplates -> Expectation +testActivationEmailUpdate templates = do let tpl = templates.activationEmailUpdate to = fromJust $ emailAddressText "test@example.com" name = Name "name" @@ -190,8 +186,8 @@ testActivationEmailUpdate branding templates = do (errs, _) = run $ runOutputList @Text $ renderActivationMail to name key code tpl branding assertNoErrors errs -testTeamActivationEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testTeamActivationEmail branding templates = do +testTeamActivationEmail :: (HasCallStack) => UserTemplates -> Expectation +testTeamActivationEmail templates = do let tpl = templates.teamActivationEmail to = fromJust $ emailAddressText "test@example.com" name = Name "name" @@ -201,8 +197,8 @@ testTeamActivationEmail branding templates = do (errs, _) = run $ runOutputList @Text $ renderTeamActivationMail to name teamName key code tpl branding assertNoErrors errs -testNewClientEmail :: (HasCallStack) => Map Text Text -> Locale -> UserTemplates -> Http () -testNewClientEmail branding loc templates = do +testNewClientEmail :: (HasCallStack) => Locale -> UserTemplates -> Expectation +testNewClientEmail loc templates = do let tpl = templates.newClientEmail to = fromJust $ emailAddressText "test@example.com" name = Name "name" @@ -222,8 +218,8 @@ testNewClientEmail branding loc templates = do (errs, _) = run $ runOutputList @Text $ renderNewClientEmail to name loc client tpl branding assertNoErrors errs -testAccountDeletionEmail :: (HasCallStack) => Map Text Text -> UserTemplates -> Http () -testAccountDeletionEmail branding templates = do +testAccountDeletionEmail :: (HasCallStack) => UserTemplates -> Expectation +testAccountDeletionEmail templates = do let tpl = templates.deletionEmail to = fromJust $ emailAddressText "test@example.com" name = Name "name" @@ -232,7 +228,7 @@ testAccountDeletionEmail branding templates = do (errs, _) = run $ runOutputList @Text $ renderDeletionEmail to name key code tpl branding assertNoErrors errs -assertNoErrors :: [Text] -> Http () +assertNoErrors :: (HasCallStack) => [Text] -> Expectation assertNoErrors errs = - liftIO $ - assertBool ("The following variables were not replaced: " <> show (nub errs)) (null errs) + unless (null errs) $ + expectationFailure ("The following variables were not replaced: " <> show (nub errs)) diff --git a/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs index fd7cf6f7631..11ab3c43f28 100644 --- a/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/IdPSubsystem/InterpreterSpec.hs @@ -37,7 +37,6 @@ import System.Logger.Message qualified as Log import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck -import Test.QuickCheck.Gen import Wire.API.Team.Member import Wire.API.User import Wire.API.User.IdentityProvider @@ -318,15 +317,9 @@ spec = describe "IdPSubsystem.Interpreter" $ do result `shouldBe` Right Nothing expectedSevereLogs logs mempty - prop "returns Nothing for non SCIM/SSO user" $ \(teamMember :: TeamMember) user idp userRef email teamId -> do - (userIdentity, userManagedBy) <- - generate $ - ( do - ui <- Test.QuickCheck.Gen.elements [Just (SSOIdentity (UserSSOId userRef) (Just email)), Nothing] - mngtBy :: ManagedBy <- arbitrary - pure (ui, mngtBy) - ) - `suchThat` (\(ui, mngtBy) -> isNothing ui || mngtBy == ManagedByWire) + prop "returns Nothing for non SSO user" $ \(teamMember :: TeamMember) user idp email teamId -> do + userManagedBy <- generate (arbitrary :: Gen ManagedBy) + userIdentity <- generate (oneof [pure Nothing, pure $ Just (EmailIdentity email)]) let userWithEmail = user diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs new file mode 100644 index 00000000000..eb5ac1d066e --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MeetingNotifierSpec.hs @@ -0,0 +1,175 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.MeetingNotifierSpec (spec) where + +import Control.Concurrent.Async qualified as A +import Data.Aeson (Result (..), Value (Object), fromJSON) +import Data.Domain (Domain (..)) +import Data.Id +import Data.Qualified (Qualified (..)) +import Data.Range (unsafeRange) +import Data.Time.Calendar (Day (ModifiedJulianDay)) +import Data.Time.Clock (UTCTime (..), addUTCTime) +import Imports +import Polysemy +import Polysemy.State +import Polysemy.TinyLog (TinyLog) +import Test.Hspec +import Wire.API.Event.Meeting qualified as MeetingEvent +import Wire.API.Meeting qualified as API +import Wire.MeetingNotifier +import Wire.MeetingNotifier.Interpreter +import Wire.MeetingsStore qualified as Store +import Wire.MockInterpreters.Now (interpretNowConst) +import Wire.NotificationSubsystem +import Wire.Sem.Logger.TinyLog (discardTinyLogs) +import Wire.Sem.Now (Now) + +spec :: Spec +spec = do + describe "interpretMeetingNotifier" $ do + it "pushes a member-add event for each alive meeting" $ do + let now = UTCTime (ModifiedJulianDay 60000) 0 + actor = Id $ read "00000000-0000-0000-0000-000000000001" + addedUser = Id $ read "00000000-0000-0000-0000-000000000002" + convId = Id $ read "00000000-0000-0000-0000-000000000003" + meetingId = Id $ read "00000000-0000-0000-0000-000000000004" + domain = Domain "local.example.com" + meeting = storedMeeting meetingId convId now (addUTCTime 60 now) + expectedEvent = + MeetingEvent.Event + { evtType = MeetingEvent.MemberAdd, + evtMeeting = Qualified meetingId domain, + evtConv = Qualified convId domain, + evtFrom = MeetingEvent.EventFromUser (Qualified actor domain), + evtTime = now, + evtTeam = Nothing + } + + pushes <- + runMemberAdded now [meeting] $ + notifyMeetingMembersAdded + (Qualified actor domain) + (Qualified convId domain) + Nothing + [addedUser] + + length pushes `shouldBe` 1 + let push = head pushes + push.recipients `shouldBe` [userRecipient addedUser] + case fromJSON (Object push.json) :: Result MeetingEvent.Event of + Error err -> expectationFailure err + Success event -> event `shouldBe` expectedEvent + + it "does not push when no alive meeting exists" $ do + let now = UTCTime (ModifiedJulianDay 60000) 0 + actor = Id $ read "00000000-0000-0000-0000-000000000001" + addedUser = Id $ read "00000000-0000-0000-0000-000000000002" + convId = Id $ read "00000000-0000-0000-0000-000000000003" + domain = Domain "local.example.com" + + pushes <- + runMemberAdded now [] $ + notifyMeetingMembersAdded + (Qualified actor domain) + (Qualified convId domain) + Nothing + [addedUser] + + pushes `shouldBe` [] + + it "does not push when the alive meeting belongs to a different conversation" $ do + let now = UTCTime (ModifiedJulianDay 60000) 0 + actor = Id $ read "00000000-0000-0000-0000-000000000001" + addedUser = Id $ read "00000000-0000-0000-0000-000000000002" + convId = Id $ read "00000000-0000-0000-0000-000000000003" + otherConvId = Id $ read "00000000-0000-0000-0000-000000000005" + meetingId = Id $ read "00000000-0000-0000-0000-000000000004" + domain = Domain "local.example.com" + meeting = storedMeeting meetingId otherConvId now (addUTCTime 60 now) + + pushes <- + runMemberAdded now [meeting] $ + notifyMeetingMembersAdded + (Qualified actor domain) + (Qualified convId domain) + Nothing + [addedUser] + + pushes `shouldBe` [] + +runMemberAdded :: + UTCTime -> + [Store.StoredMeeting] -> + Sem + '[ MeetingNotifier, + Store.MeetingsStore, + NotificationSubsystem, + Now, + TinyLog, + State [Push], + Embed IO + ] + () -> + IO [Push] +runMemberAdded now meetings = + runM + . execState ([] :: [Push]) + . discardTinyLogs + . interpretNowConst now + . captureNotifications + . interpretMeetingsStore meetings + . interpretMeetingNotifier + +captureNotifications :: + (Member (State [Push]) r, Member (Embed IO) r) => + InterpreterFor NotificationSubsystem r +captureNotifications = interpret $ \case + PushNotificationAsync push -> + modify (<> [push]) >> embed (A.async (pure (Just ()))) + _ -> error "unexpected notification operation" + +interpretMeetingsStore :: + [Store.StoredMeeting] -> + InterpreterFor Store.MeetingsStore r +interpretMeetingsStore meetings = interpret $ \case + Store.ListMeetingsByConversation convId _ -> + pure (filter ((== convId) . Store.conversationId) meetings) + _ -> error "unexpected meetings store operation" + +storedMeeting :: + MeetingId -> + ConvId -> + UTCTime -> + UTCTime -> + Store.StoredMeeting +storedMeeting meetingId convId startTime endTime = + Store.StoredMeeting + { Store.id = meetingId, + Store.title = unsafeRange "Meeting", + Store.creator = Id $ read "00000000-0000-0000-0000-000000000001", + Store.startTime = startTime, + Store.endTime = endTime, + Store.tzid = API.defaultLegacyTimeZone, + Store.recurrence = Nothing, + Store.conversationId = convId, + Store.invitedEmails = [], + Store.trial = False, + Store.createdAt = startTime, + Store.updatedAt = startTime + } diff --git a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs index 70960254a73..145bf63ba8b 100644 --- a/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/MeetingsSubsystem/InterpreterSpec.hs @@ -17,6 +17,7 @@ module Wire.MeetingsSubsystem.InterpreterSpec (spec) where +import Data.Aeson (Result (..), Value (Object), fromJSON) import Data.ByteString.Char8 qualified as C import Data.Default (def) import Data.Domain (Domain (..)) @@ -29,30 +30,39 @@ import Data.Set qualified as Set import Data.Tagged (Tagged) import Data.Time.Calendar (fromGregorian) import Data.Time.Clock +import Data.UUID (nil) +import Data.Vector qualified as V import Imports import Polysemy import Polysemy.Error import Polysemy.Input import Polysemy.State +import Polysemy.TinyLog (TinyLog) import System.Random (StdGen, mkStdGen) import Test.Hspec import Test.Hspec.QuickCheck (prop) -import Test.QuickCheck (counterexample, ioProperty, (.&&.), (===), (==>)) +import Test.QuickCheck (NonNegative, counterexample, getNonNegative, ioProperty, (.&&.), (===), (==>)) import Text.Email.Parser (unsafeEmailAddress) -import Wire.API.Conversation (Access (InviteAccess, PrivateAccess)) +import Wire.API.Conversation (Access (CodeAccess, InviteAccess), Conversation (metadata, qualifiedId), ConversationMetadata (cnvmAccess)) import Wire.API.Error (ErrorS) -import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) +import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound), InvalidTimesReason (..), MeetingError (..)) +import Wire.API.Event.Meeting qualified as MeetingEvent import Wire.API.Meeting qualified as API +import Wire.API.PostgresMarshall (PostgresUnmarshall (postgresUnmarshall)) import Wire.API.Team.Feature import Wire.API.Team.Member (TeamMember, mkTeamMember) import Wire.API.Team.Permission (fullPermissions) import Wire.ConversationSubsystem import Wire.FeaturesConfigSubsystem import Wire.GalleyAPIAccess (GalleyAPIAccess) +import Wire.MeetingNotifier (MeetingNotifier, notifyMeetingEvent) +import Wire.MeetingNotifier.Interpreter (interpretMeetingNotifier) import Wire.MeetingsStore qualified as Store import Wire.MeetingsSubsystem import Wire.MeetingsSubsystem.Interpreter import Wire.MockInterpreters +import Wire.NotificationSubsystem (NotificationSubsystem, Push (..), Recipient (recipientUserId)) +import Wire.Sem.Logger.TinyLog (discardTinyLogs) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.StoredConversation @@ -61,14 +71,18 @@ import Wire.TeamSubsystem.GalleyAPI type TestStack = '[ MeetingsSubsystem, + MeetingNotifier, Store.MeetingsStore, ConversationSubsystem, TeamSubsystem, FeaturesConfigSubsystem, + NotificationSubsystem, + TinyLog, Error MeetingError, State (Map MeetingId Store.StoredMeeting), State (Map ConvId StoredConversation), State (Map ConvId (Set UserId)), + State [Push], GalleyAPIAccess, Now, State UTCTime, @@ -114,15 +128,31 @@ runTestStack now gen teams configs = . evalState now . interpretNowAsState . miniGalleyAPIAccess teams configs + . evalState ([] :: [Push]) . evalState Map.empty . evalState Map.empty . evalState Map.empty . runError @MeetingError + . discardTinyLogs + . inMemoryNotificationSubsystemInterpreter . interpretFeaturesConfigSubsystemPure configs . interpretTeamSubsystemToGalleyAPI . inMemoryConversationSubsystemInterpreter . inMemoryMeetingsStoreInterpreter - . interpretMeetingsSubsystem 3600 + . interpretMeetingNotifier + . interpretMeetingsSubsystem + MeetingSystemConfig + { legacyTimeZone = API.defaultLegacyTimeZone, + validityPeriod = 3600, + pastEditPeriod = configuredPastEditPeriod + } + +-- | Decode all 'Push' payloads that are meeting lifecycle events. Any push that +-- decodes as a 'MeetingEvent.Event' is one: conversation events use distinct +-- @type@ tags that the meeting 'EventType' enum rejects. +extractMeetingEvents :: [Push] -> [MeetingEvent.Event] +extractMeetingEvents pushes = + [e | push <- pushes, Success e <- [fromJSON (Object push.json)]] spec :: Spec spec = describe "MeetingsSubsystem.Interpreter" $ do @@ -136,22 +166,24 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty def $ do - (meeting, _conv) <- createMeeting zUser newMeeting - fetched <- getMeeting zUser meeting.id + meeting <- createMeeting zUser (ConnId "test-conn") newMeeting + fetched <- getMeeting zUser meeting.meeting.id pure (meeting, fetched) case result of Left err -> fail $ "Error: " <> show err Right (meeting, fetched) -> do - meeting.title `shouldBe` fromJust (checked "Test Meeting") - fetched `shouldBe` Just meeting + meeting.meeting.title `shouldBe` fromJust (checked "Test Meeting") + meeting.conversation.qualifiedId `shouldBe` meeting.meeting.conversationId + fetched `shouldBe` Just meeting.meeting - it "creates meeting conversation with invite access for MLS participant adds" $ do + it "creates meeting conversation with invite and code access" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 gen = mkStdGen 42 uid = Id $ read "00000000-0000-0000-0000-000000000001" @@ -161,21 +193,21 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Access Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty def $ do - (_meeting, conv) <- createMeeting zUser newMeeting - pure (convAccess conv) + meeting <- createMeeting zUser (ConnId "test-conn") newMeeting + pure meeting.conversation.metadata.cnvmAccess case result of Left err -> fail $ "Error: " <> show err Right access -> do - PrivateAccess `elem` access `shouldBe` True - InviteAccess `elem` access `shouldBe` True + access `shouldBe` [InviteAccess, CodeAccess] - it "fails to create a meeting if end time is before start time" $ do + it "fails to create a meeting if end_time is not after start_time" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 gen = mkStdGen 42 uid = Id $ read "00000000-0000-0000-0000-000000000001" @@ -185,12 +217,70 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Invalid Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 3500 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } - result <- runTestStack now gen Map.empty def $ createMeeting zUser newMeeting - result `shouldBe` Left InvalidTimes + result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting + result `shouldBe` Left (InvalidTimes EndBeforeStart) + + it "fails to create a meeting if start time is in the past" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid = Id $ read "00000000-0000-0000-0000-000000000001" + zUser = toLocalUnsafe (Domain "wire.com") uid + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Past Meeting", + startTime = addUTCTime (negate 3600) now, + endTime = addUTCTime 3600 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting + result `shouldBe` Left (InvalidTimes StartTimeTooFarInPast) + + it "accepts a meeting whose start time is exactly at the tolerance boundary" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid = Id $ read "00000000-0000-0000-0000-000000000001" + zUser = toLocalUnsafe (Domain "wire.com") uid + -- Exactly `expectedStartTimeTolerance` in the past: the check is strict (`<`), + -- so the boundary itself is still accepted. + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Boundary Meeting", + startTime = addUTCTime (negate expectedStartTimeTolerance) now, + endTime = addUTCTime 3600 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting + result `shouldSatisfy` isRight + + it "rejects a meeting whose start time is just past the tolerance boundary" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid = Id $ read "00000000-0000-0000-0000-000000000001" + zUser = toLocalUnsafe (Domain "wire.com") uid + -- One second past the tolerance boundary. + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Just Past Boundary Meeting", + startTime = addUTCTime (negate (expectedStartTimeTolerance + 1)) now, + endTime = addUTCTime 3600 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty def $ createMeeting zUser (ConnId "test-conn") newMeeting + result `shouldBe` Left (InvalidTimes StartTimeTooFarInPast) describe "getMeeting access control" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 @@ -204,21 +294,23 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamId = Id $ read "00000000-0000-0000-0000-000000000100" teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled - teamConfig = npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + teamConfig = npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def it "returns Nothing for expired meeting" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Past Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + passTime validityWindow + getMeeting zUser1 meeting.meeting.id result `shouldBe` Right Nothing @@ -228,17 +320,18 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Creator Access Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - (meeting,) <$> getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + (meeting,) <$> getMeeting zUser1 meeting.meeting.id case result of Left err -> fail $ "Error: " <> show err - Right (meeting, Just m) -> m.id `shouldBe` meeting.id + Right (meeting, Just m) -> m.id `shouldBe` meeting.meeting.id Right (_, Nothing) -> fail "Expected Just meeting for creator" it "returns meeting for conversation member" $ do @@ -247,20 +340,21 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Member Access Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, conv) <- createMeeting zUser1 newMeeting - members <- gets (Map.lookup conv.id_) + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + members <- gets (Map.lookup (qUnqualified meeting.conversation.qualifiedId)) let updatedMembers = maybe (Set.singleton uid2) (Set.insert uid2) members - modify (Map.insert conv.id_ updatedMembers) - (meeting,) <$> getMeeting zUser2 meeting.id + modify (Map.insert (qUnqualified meeting.conversation.qualifiedId) updatedMembers) + (meeting,) <$> getMeeting zUser2 meeting.meeting.id case result of Left err -> fail $ "Error: " <> show err - Right (meeting, Just m) -> m.id `shouldBe` meeting.id + Right (meeting, Just m) -> m.id `shouldBe` meeting.meeting.id Right (_, Nothing) -> fail "Expected Just meeting for conversation member" it "returns Nothing for unauthorized user" $ do @@ -269,88 +363,17 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Unauthorized Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - getMeeting zUser3 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + getMeeting zUser3 meeting.meeting.id result `shouldBe` Right Nothing - it "creates trial meeting for personal user" $ do - let now = UTCTime (fromGregorian 2026 1 1) 0 - gen = mkStdGen 42 - uid = Id $ read "00000000-0000-0000-0000-000000000001" - zUser = toLocalUnsafe (Domain "wire.com") uid - newMeeting = - API.NewMeeting - { title = fromJust $ checked "Personal Meeting", - startTime = addUTCTime 3600 now, - endTime = addUTCTime 7200 now, - recurrence = Nothing, - invitedEmails = [] - } - - result <- runTestStack now gen Map.empty def $ do - (meeting, _conv) <- createMeeting zUser newMeeting - pure meeting - - fmap (.trial) result `shouldBe` Right True - - it "creates meeting with trial flag when premium is enabled for team" $ do - let now = UTCTime (fromGregorian 2026 1 1) 0 - gen = mkStdGen 42 - uid = Id $ read "00000000-0000-0000-0000-000000000001" - zUser = toLocalUnsafe (Domain "wire.com") uid - teamId = Id $ read "00000000-0000-0000-0000-000000000100" - teamMember = mkTeamMember uid fullPermissions Nothing UserLegalHoldDisabled - teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - $ def - newMeeting = - API.NewMeeting - { title = fromJust $ checked "Team Premium Meeting", - startTime = addUTCTime 3600 now, - endTime = addUTCTime 7200 now, - recurrence = Nothing, - invitedEmails = [] - } - - result <- runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser newMeeting - pure meeting - - fmap (.trial) result `shouldBe` Right False - - it "creates meeting without trial flag when premium is disabled for team" $ do - let now = UTCTime (fromGregorian 2026 1 1) 0 - gen = mkStdGen 42 - uid = Id $ read "00000000-0000-0000-0000-000000000001" - zUser = toLocalUnsafe (Domain "wire.com") uid - teamId = Id $ read "00000000-0000-0000-0000-000000000100" - teamMember = mkTeamMember uid fullPermissions Nothing UserLegalHoldDisabled - teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusDisabled LockStatusUnlocked def) - $ def - newMeeting = - API.NewMeeting - { title = fromJust $ checked "Team Free Meeting", - startTime = addUTCTime 3600 now, - endTime = addUTCTime 7200 now, - recurrence = Nothing, - invitedEmails = [] - } - - result <- runTestStack now gen (Map.singleton teamId [teamMember]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser newMeeting - pure meeting - - fmap (.trial) result `shouldBe` Right True - describe "updateMeeting" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 gen = mkStdGen 42 @@ -362,7 +385,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def it "throws EmptyUpdate when no fields provided" $ do let newMeeting = @@ -370,52 +393,245 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Original Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - updateMeeting zUser1 meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing) + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing Nothing Nothing Nothing) result `shouldBe` Left EmptyUpdate - it "throws InvalidTimes when startTime >= endTime" $ do + it "updates tzid when provided alone" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Tzid Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + newTz = fromJust (API.parseTimeZone "Europe/London") + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + updateMeeting + zUser1 + (ConnId "test-conn") + meeting.meeting.id + (API.UpdateMeeting Nothing Nothing Nothing Nothing (Just newTz)) + case result of + Left err -> fail $ "Expected the update to be applied, got: " <> show err + Right Nothing -> fail "Expected the update to be applied" + Right (Just updated) -> do + updated.meeting.tzid `shouldBe` newTz + updated.meeting.title `shouldBe` newMeeting.title + + it "throws InvalidTimes when end_time is not after start_time" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Original Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + let update = + API.UpdateMeeting + { startTime = Nothing, + endTime = Just (addUTCTime 3600 now), + title = Nothing, + recurrence = Nothing, + tzid = Nothing + } + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update + result `shouldBe` Left (InvalidTimes EndBeforeStart) + + it "allows editing an upcoming meeting's start time into the past within the past-edit window" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Original Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + let update = + API.UpdateMeeting + { startTime = Just (addUTCTime (negate 60) now), + endTime = Nothing, + title = Nothing, + recurrence = Nothing, + tzid = Nothing + } + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update + + case result of + Left err -> fail $ "Expected the update to be applied, got: " <> show err + Right Nothing -> fail "Expected the update to be applied" + Right (Just updated) -> + updated.meeting.startTime `shouldBe` addUTCTime (negate 60) now + + it "accepts a meeting update whose start time is exactly at the past-edit boundary" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Original Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting let update = API.UpdateMeeting - { startTime = Just (addUTCTime 8000 now), + { startTime = Just (addUTCTime (negate configuredPastEditPeriod) now), endTime = Nothing, title = Nothing, - recurrence = Nothing + recurrence = Nothing, + tzid = Nothing } - updateMeeting zUser1 meeting.id update + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update - result `shouldBe` Left InvalidTimes + case result of + Right (Just _) -> pure () + other -> fail $ "Expected the update to be applied, got: " <> show other + + it "rejects a meeting update whose start time is just past the past-edit boundary" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Original Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + let update = + API.UpdateMeeting + { startTime = Just (addUTCTime (negate (configuredPastEditPeriod + 1)) now), + endTime = Nothing, + title = Nothing, + recurrence = Nothing, + tzid = Nothing + } + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update + result `shouldBe` Left (InvalidTimes TimesBeyondPastEditWindow) + + it "throws InvalidTimes when updating both times beyond the past-edit window even when start < end" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Original Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + let update = + API.UpdateMeeting + { startTime = Just (addUTCTime (negate 7200) now), + endTime = Just (addUTCTime (negate (configuredPastEditPeriod + 1)) now), + title = Nothing, + recurrence = Nothing, + tzid = Nothing + } + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update + result `shouldBe` Left (InvalidTimes TimesBeyondPastEditWindow) + + it "allows editing an already-started (ongoing) meeting, including its start time" $ do + let ongoingMeeting = + API.NewMeeting + { title = fromJust $ checked "Ongoing Meeting", + startTime = addUTCTime 100 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + result <- + runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") ongoingMeeting + -- Advance the clock 3000s: startTime (now+100s) is now in the past, so + -- the meeting has started. It stays editable because isAlive is + -- effective-end-based (end_time = now+7200s), still past + -- the alive-cutoff (now+3000s-3600s = now-600s). + passTime 3000 + let update = + API.UpdateMeeting + { -- Original start time, which is now in the past. + startTime = Just (addUTCTime 100 now), + endTime = Nothing, + title = Just (unsafeRange "Edited While Ongoing"), + recurrence = Nothing, + tzid = Nothing + } + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update + case result of + Left err -> + fail $ "Expected the ongoing meeting to be editable, got: " <> show err + Right Nothing -> fail "Expected the update to be applied" + Right (Just updated) -> + updated.meeting.title `shouldBe` unsafeRange "Edited While Ongoing" + + it "still throws InvalidTimes when editing an ongoing meeting to startTime >= endTime" $ do + let ongoingMeeting = + API.NewMeeting + { title = fromJust $ checked "Ongoing Meeting", + startTime = addUTCTime 100 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + result <- + runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") ongoingMeeting + passTime 3000 + let update = + API.UpdateMeeting + { startTime = Just (addUTCTime 8000 now), + endTime = Nothing, + title = Nothing, + recurrence = Nothing, + tzid = Nothing + } + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id update + result `shouldBe` Left (InvalidTimes EndBeforeStart) it "returns Nothing for expired meeting" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - updateMeeting zUser1 meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + passTime validityWindow + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing Nothing) result `shouldBe` Right Nothing @@ -425,13 +641,33 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Non-creator Update", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - updateMeeting zUser2 meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing) + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Test")) Nothing Nothing) + + result `shouldBe` Right Nothing + + it "returns Nothing when the meeting's conversation is missing" $ do + let newMeeting = + API.NewMeeting + { title = fromJust $ checked "Orphaned Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + result <- runTestStack now gen Map.empty teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + -- Simulate a data-inconsistency: the meeting's conversation vanished. + modify @(Map ConvId StoredConversation) (Map.delete (qUnqualified meeting.meeting.conversationId)) + updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) result `shouldBe` Right Nothing @@ -441,29 +677,44 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Original Title", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } - effectiveStart = fromMaybe baseMeeting.startTime update.startTime - effectiveEnd = fromMaybe baseMeeting.endTime update.endTime - isNotEmpty = update /= API.UpdateMeeting Nothing Nothing Nothing Nothing - hasValidTimes = effectiveStart < effectiveEnd + -- Clamp the updated start/end times so they are not in the past + -- beyond the past-edit window. This avoids discarding + -- QuickCheck-generated updates whose arbitrary UTCTime is far from + -- `now`. + sanitizedUpdate = + API.UpdateMeeting + (fmap (max (addUTCTime (negate 60) now)) update.startTime) + (fmap (max (addUTCTime (negate 60) now)) update.endTime) + update.title + update.recurrence + update.tzid + effectiveStart = fromMaybe baseMeeting.startTime sanitizedUpdate.startTime + effectiveEndTime = fromMaybe baseMeeting.endTime sanitizedUpdate.endTime + isNotEmpty = sanitizedUpdate /= API.UpdateMeeting Nothing Nothing Nothing Nothing Nothing + hasValidTimes = effectiveEndTime > effectiveStart in isNotEmpty && hasValidTimes ==> ioProperty $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 baseMeeting - updateMeeting zUser1 meeting.id update + meeting <- createMeeting zUser1 (ConnId "test-conn") baseMeeting + updated <- updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id sanitizedUpdate + pure (meeting.meeting.conversationId, updated) case result of Left err -> pure $ counterexample ("Unexpected error: " <> show err) False - Right Nothing -> + Right (_, Nothing) -> pure $ counterexample "Expected Just meeting, got Nothing" False - Right (Just m) -> + Right (convId, Just m) -> pure $ - m.title === fromMaybe baseMeeting.title update.title - .&&. m.startTime === effectiveStart - .&&. m.endTime === effectiveEnd - .&&. m.recurrence === fromMaybe baseMeeting.recurrence update.recurrence + m.meeting.title === fromMaybe baseMeeting.title sanitizedUpdate.title + .&&. m.meeting.startTime === effectiveStart + .&&. m.meeting.endTime === effectiveEndTime + .&&. m.meeting.recurrence === fromMaybe baseMeeting.recurrence sanitizedUpdate.recurrence + .&&. m.meeting.tzid === fromMaybe baseMeeting.tzid sanitizedUpdate.tzid + .&&. m.meeting.conversationId === convId describe "deleteMeeting" $ do let now = UTCTime (fromGregorian 2026 1 1) 0 @@ -476,7 +727,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def testConnId = ConnId (C.pack "test-conn") it "returns True for successful deletion by creator" $ do @@ -485,14 +736,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Meeting to Delete", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting - deleteResult <- deleteMeeting zUser1 testConnId meeting.id - getResult <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + deleteResult <- deleteMeeting zUser1 testConnId meeting.meeting.id + getResult <- getMeeting zUser1 meeting.meeting.id pure (deleteResult, getResult) result `shouldBe` Right (True, Nothing) @@ -503,13 +755,14 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Meeting to Delete", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting - deleteMeeting zUser2 testConnId meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + deleteMeeting zUser2 testConnId meeting.meeting.id result `shouldBe` Right False @@ -517,15 +770,17 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting - deleteMeeting zUser1 testConnId meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + passTime validityWindow + deleteMeeting zUser1 testConnId meeting.meeting.id result `shouldBe` Right False @@ -543,15 +798,16 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Meeting to Delete", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, conv) <- createMeeting zUser1 newMeeting - _ <- internalGetConversation conv.id_ - _ <- deleteMeeting zUser1 testConnId meeting.id - internalGetConversation conv.id_ + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + _ <- internalGetConversation (qUnqualified meeting.conversation.qualifiedId) + _ <- deleteMeeting zUser1 testConnId meeting.meeting.id + internalGetConversation (qUnqualified meeting.conversation.qualifiedId) result `shouldBe` Right Nothing @@ -561,15 +817,16 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Meeting to Delete", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _) <- createMeeting zUser1 newMeeting + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting -- Change conversation type to non-meeting by updating local members only -- This simulates a non-meeting conversation without touching internal types - deleteMeeting zUser1 testConnId meeting.id + deleteMeeting zUser1 testConnId meeting.meeting.id result `shouldSatisfy` isRight @@ -584,7 +841,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def email1 = unsafeEmailAddress "user1" "example.com" email2 = unsafeEmailAddress "user2" "example.com" @@ -594,14 +851,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- addInvitedEmails zUser1 meeting.id [email1, email2] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + success <- addInvitedEmails zUser1 meeting.meeting.id [email1, email2] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -615,15 +873,17 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - addInvitedEmails zUser1 meeting.id [email1] + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + passTime validityWindow + addInvitedEmails zUser1 meeting.meeting.id [email1] result `shouldBe` Right False @@ -633,13 +893,14 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Non-creator Test", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - addInvitedEmails zUser2 meeting.id [email1] + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + addInvitedEmails zUser2 meeting.meeting.id [email1] result `shouldBe` Right False @@ -663,7 +924,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def email1 = unsafeEmailAddress "user1" "example.com" email2 = unsafeEmailAddress "user2" "example.com" email3 = unsafeEmailAddress "user3" "example.com" @@ -674,14 +935,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1, email2, email3] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- removeInvitedEmails zUser1 meeting.id [email2] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + success <- removeInvitedEmails zUser1 meeting.meeting.id [email2] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -697,14 +959,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1, email2] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- removeInvitedEmails zUser1 meeting.id [email1, email2] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + success <- removeInvitedEmails zUser1 meeting.meeting.id [email1, email2] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -720,14 +983,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- removeInvitedEmails zUser1 meeting.id [email2, email3] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + success <- removeInvitedEmails zUser1 meeting.meeting.id [email2, email3] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -741,15 +1005,17 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - removeInvitedEmails zUser1 meeting.id [email1] + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + passTime validityWindow + removeInvitedEmails zUser1 meeting.meeting.id [email1] result `shouldBe` Right False @@ -759,13 +1025,14 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Non-creator Test", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1, email2] } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - removeInvitedEmails zUser2 meeting.id [email1] + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + removeInvitedEmails zUser2 meeting.meeting.id [email1] result `shouldBe` Right False @@ -789,7 +1056,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ def email1 = unsafeEmailAddress "user1" "example.com" email2 = unsafeEmailAddress "user2" "example.com" email3 = unsafeEmailAddress "user3" "example.com" @@ -800,14 +1067,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1, email2] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- replaceInvitedEmails zUser1 meeting.id [email3] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + success <- replaceInvitedEmails zUser1 meeting.meeting.id [email3] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -823,14 +1091,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1, email2] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- replaceInvitedEmails zUser1 meeting.id [] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + success <- replaceInvitedEmails zUser1 meeting.meeting.id [] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -846,14 +1115,15 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1, email2] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - success <- replaceInvitedEmails zUser1 meeting.id [email3, email3, email1] - fetched <- getMeeting zUser1 meeting.id + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + success <- replaceInvitedEmails zUser1 meeting.meeting.id [email3, email3, email1] + fetched <- getMeeting zUser1 meeting.meeting.id pure (success, fetched) case result of @@ -867,15 +1137,17 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do let newMeeting = API.NewMeeting { title = fromJust $ checked "Expired Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1] } result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - replaceInvitedEmails zUser1 meeting.id [email2] + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + passTime validityWindow + replaceInvitedEmails zUser1 meeting.meeting.id [email2] result `shouldBe` Right False @@ -885,13 +1157,14 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Non-creator Test", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [email1, email2] } result <- runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do - (meeting, _conv) <- createMeeting zUser1 newMeeting - replaceInvitedEmails zUser2 meeting.id [email3] + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + replaceInvitedEmails zUser2 meeting.meeting.id [email3] result `shouldBe` Right False @@ -913,15 +1186,17 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do uid = Id $ read "00000000-0000-0000-0000-000000000001" zUser = toLocalUnsafe (Domain "wire.com") uid teamConfig = - npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - . npUpdate @MeetingsPremiumConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) - $ def - -- endTime (now-5000) is well past the validity cutoff (now-3600). - expiredNewMeeting r = + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) $ + def + -- Meetings are created with future times. We then advance the mock + -- clock past the validity window (11000s) so that the original slot + -- has passed, while the recurrence window stays open. + futureMeeting r = API.NewMeeting { title = fromJust $ checked "Recurring Meeting", - startTime = addUTCTime (-7200) now, - endTime = addUTCTime (-5000) now, + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = r, invitedEmails = [] } @@ -939,12 +1214,24 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do interval = 1, until = Nothing } + meetingAt endOffset r = + API.NewMeeting + { title = fromJust $ checked "Meeting", + startTime = addUTCTime (endOffset - 3600) now, + endTime = addUTCTime endOffset now, + tzid = API.defaultLegacyTimeZone, + recurrence = r, + invitedEmails = [] + } + recurUntil t = + Just (API.Recurrence {freq = API.Daily, interval = 1, until = Just t}) it "getMeeting returns a recurring meeting whose slot passed but window is open" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - getMeeting zUser meeting.id + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + passTime validityWindow + getMeeting zUser meeting.meeting.id case result of Left err -> fail $ "Error: " <> show err Right Nothing -> fail "Expected Just meeting (recurrence window still open)" @@ -953,7 +1240,8 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "listMeetings includes a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (_meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) + _meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + passTime validityWindow listMeetings zUser case result of Left err -> fail $ "Error: " <> show err @@ -962,53 +1250,62 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "updateMeeting succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - updateMeeting zUser meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + passTime validityWindow + updateMeeting zUser (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) fmap isJust result `shouldBe` Right True it "addInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - addInvitedEmails zUser meeting.id [unsafeEmailAddress "user" "example.com"] + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + passTime validityWindow + addInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "deleteMeeting succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - deleteMeeting zUser (ConnId "test-conv") meeting.id + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + passTime validityWindow + deleteMeeting zUser (ConnId "test-conv") meeting.meeting.id result `shouldBe` Right True it "removeInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - removeInvitedEmails zUser meeting.id [unsafeEmailAddress "user" "example.com"] + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + passTime validityWindow + removeInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "replaceInvitedEmails succeeds on a recurring meeting whose slot passed" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - replaceInvitedEmails zUser meeting.id [unsafeEmailAddress "user" "example.com"] + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + passTime validityWindow + replaceInvitedEmails zUser meeting.meeting.id [unsafeEmailAddress "user" "example.com"] result `shouldBe` Right True it "getMeeting returns an open-ended recurring meeting indefinitely" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) - getMeeting zUser meeting.id + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting openEndedRecurrence) + passTime validityWindow + getMeeting zUser meeting.meeting.id fmap isJust result `shouldBe` Right True it "cleanupOldMeetings skips recurring meetings whose window is still open" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (recurring, _conv1) <- createMeeting zUser (expiredNewMeeting boundedRecurrence) - (_plain, _conv2) <- createMeeting zUser (expiredNewMeeting Nothing) - deleted <- cleanupOldMeetings (addUTCTime (negate 1) now) 100 - remaining <- getMeeting zUser recurring.id - pure (deleted, fmap (.id) remaining, recurring.id) + recurring <- createMeeting zUser (ConnId "test-conn") (futureMeeting boundedRecurrence) + _plain <- createMeeting zUser (ConnId "test-conn") (futureMeeting Nothing) + passTime validityWindow + -- cutoff is past the endTime (now+7200) so the non-recurring + -- meeting is picked up, but well before the recurrence window. + deleted <- cleanupOldMeetings (addUTCTime 7300 now) 100 + remaining <- getMeeting zUser recurring.meeting.id + pure (deleted, fmap (.id) remaining, recurring.meeting.id) case result of Left err -> fail $ "Error: " <> show err Right (deleted, remainingId, recurringId) -> do @@ -1019,39 +1316,66 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "cleanupOldMeetings never picks up open-ended recurring meetings" $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser (expiredNewMeeting openEndedRecurrence) - deleted <- cleanupOldMeetings now 100 - remaining <- getMeeting zUser meeting.id - pure (deleted, fmap (.id) remaining, meeting.id) + meeting <- createMeeting zUser (ConnId "test-conn") (futureMeeting openEndedRecurrence) + passTime validityWindow + -- Even with a cutoff well past the endTime, open-ended + -- recurrence is never picked up. + deleted <- cleanupOldMeetings (addUTCTime validityWindow now) 100 + remaining <- getMeeting zUser meeting.meeting.id + pure (deleted, fmap (.id) remaining, meeting.meeting.id) case result of Left err -> fail $ "Error: " <> show err Right (deleted, remainingId, meetingId) -> do deleted `shouldBe` 0 remainingId `shouldBe` Just meetingId + it "cleanupOldMeetings deletes in effectiveEndTime order, not endTime order" $ do + result <- + runTestStack now gen Map.empty teamConfig $ do + -- endTime now+8000, no recurrence -> effectiveEndTime now+8000 (earliest) + plain <- createMeeting zUser (ConnId "test-conn") (meetingAt 8000 Nothing) + -- endTime now+4000, until now+10000 -> effectiveEndTime now+10000 (later) + recur <- createMeeting zUser (ConnId "test-conn") (meetingAt 4000 (recurUntil (addUTCTime 10000 now))) + _deleted <- cleanupOldMeetings (addUTCTime 11000 now) 1 + plainRemains <- isJust <$> getMeeting zUser plain.meeting.id + recurRemains <- isJust <$> getMeeting zUser recur.meeting.id + pure (plainRemains, recurRemains) + case result of + Left err -> fail $ "Error: " <> show err + Right (plainRemains, recurRemains) -> do + -- plain has the earlier effectiveEndTime, so it is deleted first; + -- recur survives. With the old endTime sort, recur (endTime now+4000) + -- would be deleted first instead. + plainRemains `shouldBe` False + recurRemains `shouldBe` True + prop "aliveness follows effectiveEndTime across get/list/cleanup" $ - \(recurrence :: Maybe API.Recurrence) (endOffset :: Int) -> - let endTime = addUTCTime (fromIntegral endOffset) now - startTime = addUTCTime (negate 3600) endTime + \(recurrence :: Maybe API.Recurrence) (advance :: NonNegative Int) -> + let startTime = addUTCTime 3600 now + endTime = addUTCTime 7200 now nm = API.NewMeeting { title = fromJust $ checked "Recurring Meeting", startTime = startTime, - endTime = endTime, + endTime = addUTCTime 3600 startTime, + tzid = API.defaultLegacyTimeZone, recurrence = recurrence, invitedEmails = [] } + advanceTime = fromIntegral (getNonNegative advance) :: NominalDiffTime + -- After advancing the clock, the validity cutoff moves forward. + cutoff = addUTCTime (advanceTime - 3600) now effEnd = maybe (Just endTime) (\r -> max endTime <$> r.until) recurrence - cutoff = addUTCTime (negate 3600) now alive = maybe True (>= cutoff) effEnd in ioProperty $ do result <- runTestStack now gen Map.empty teamConfig $ do - (meeting, _conv) <- createMeeting zUser nm - fetched <- isJust <$> getMeeting zUser meeting.id + meeting <- createMeeting zUser (ConnId "test-conn") nm + passTime advanceTime + fetched <- isJust <$> getMeeting zUser meeting.meeting.id listedCount <- length <$> listMeetings zUser deleted <- cleanupOldMeetings cutoff 100 - remains <- isJust <$> getMeeting zUser meeting.id + remains <- isJust <$> getMeeting zUser meeting.meeting.id pure (fetched, listedCount, deleted, remains) pure $ case result of Left err -> counterexample ("Unexpected error: " <> show err) False @@ -1079,6 +1403,7 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do { title = fromJust $ checked "Test Meeting", startTime = addUTCTime 3600 now, endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, recurrence = Nothing, invitedEmails = [] } @@ -1086,117 +1411,372 @@ spec = describe "MeetingsSubsystem.Interpreter" $ do it "allows operations for personal user even when meetings disabled" $ do result <- runTestStack now gen Map.empty meetingsDisabled $ - createMeeting zUserPersonal newMeeting + createMeeting zUserPersonal (ConnId "test-conn") newMeeting result `shouldSatisfy` isRight it "allows operations for team user with meetings enabled" $ do result <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ - createMeeting zUserTeam newMeeting + createMeeting zUserTeam (ConnId "test-conn") newMeeting result `shouldSatisfy` isRight it "throws MeetingsFeatureDisabled on createMeeting for team user with meetings disabled" $ do result <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - createMeeting zUserTeam newMeeting + createMeeting zUserTeam (ConnId "test-conn") newMeeting result `shouldBe` Left MeetingsFeatureDisabled - it "throws MeetingsFeatureDisabled on getMeeting for team user with meetings disabled" $ do + it "returns Nothing on getMeeting for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam (ConnId "test-conn") newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - getMeeting zUserTeam meeting.id + getMeeting zUserTeam meeting.meeting.id - result2 `shouldBe` Left MeetingsFeatureDisabled + result2 `shouldBe` Right Nothing it "throws MeetingsFeatureDisabled on updateMeeting for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam (ConnId "test-conn") newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - updateMeeting zUserTeam meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing) + updateMeeting zUserTeam (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) result2 `shouldBe` Left MeetingsFeatureDisabled it "throws MeetingsFeatureDisabled on deleteMeeting for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam (ConnId "test-conn") newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - deleteMeeting zUserTeam (ConnId "test-conn") meeting.id + deleteMeeting zUserTeam (ConnId "test-conn") meeting.meeting.id result2 `shouldBe` Left MeetingsFeatureDisabled - it "throws MeetingsFeatureDisabled on listMeetings for team user with meetings disabled" $ do + it "returns [] on listMeetings for team user with meetings disabled" $ do result <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ listMeetings zUserTeam - result `shouldBe` Left MeetingsFeatureDisabled + result `shouldBe` Right [] it "throws MeetingsFeatureDisabled on addInvitedEmails for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam (ConnId "test-conn") newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - addInvitedEmails zUserTeam meeting.id [unsafeEmailAddress "test" "example.com"] + addInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled it "throws MeetingsFeatureDisabled on removeInvitedEmails for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam (ConnId "test-conn") newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - removeInvitedEmails zUserTeam meeting.id [unsafeEmailAddress "test" "example.com"] + removeInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled it "throws MeetingsFeatureDisabled on replaceInvitedEmails for team user with meetings disabled" $ do result <- - runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ do - (meeting, _conv) <- createMeeting zUserTeam newMeeting - pure meeting + runTestStack now gen (Map.singleton teamId [teamMember]) meetingsEnabled $ + createMeeting zUserTeam (ConnId "test-conn") newMeeting case result of Left err -> fail $ "Failed to create meeting: " <> show err Right meeting -> do result2 <- runTestStack now gen (Map.singleton teamId [teamMember]) meetingsDisabled $ - replaceInvitedEmails zUserTeam meeting.id [unsafeEmailAddress "test" "example.com"] + replaceInvitedEmails zUserTeam meeting.meeting.id [unsafeEmailAddress "test" "example.com"] result2 `shouldBe` Left MeetingsFeatureDisabled + + describe "meeting events" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid1 = Id $ read "00000000-0000-0000-0000-000000000001" + uid2 = Id $ read "00000000-0000-0000-0000-000000000002" + zUser1 = toLocalUnsafe (Domain "wire.com") uid1 + zUser2 = toLocalUnsafe (Domain "wire.com") uid2 + teamId = Id $ read "00000000-0000-0000-0000-000000000100" + teamMember1 = mkTeamMember uid1 fullPermissions Nothing UserLegalHoldDisabled + teamMember2 = mkTeamMember uid2 fullPermissions Nothing UserLegalHoldDisabled + teamConfig = + npUpdate @MeetingsConfig (LockableFeature FeatureStatusEnabled LockStatusUnlocked def) def + newMeeting = + API.NewMeeting + { title = fromJust $ checked "Event Test Meeting", + startTime = addUTCTime 3600 now, + endTime = addUTCTime 7200 now, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + + it "emits a meeting.create event on successful create" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + pushes <- get @[Push] + pure (meeting, pushes) + + case result of + Left err -> fail $ "Error: " <> show err + Right (meeting, pushes) -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + (head events).evtType `shouldBe` MeetingEvent.Create + (head events).evtMeeting `shouldBe` meeting.meeting.id + + it "emits a meeting.update event on successful update" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + put @[Push] [] + _ <- updateMeeting zUser1 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Updated")) Nothing Nothing) + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + (head events).evtType `shouldBe` MeetingEvent.Update + + it "emits a meeting.delete event on successful delete" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1]) teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + put @[Push] [] + _ <- deleteMeeting zUser1 (ConnId "test-conn") meeting.meeting.id + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> do + let events = extractMeetingEvents pushes + length events `shouldBe` 1 + (head events).evtType `shouldBe` MeetingEvent.Delete + + it "does not emit an event when updateMeeting fails (non-creator)" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + put @[Push] [] + _ <- updateMeeting zUser2 (ConnId "test-conn") meeting.meeting.id (API.UpdateMeeting Nothing Nothing (Just (unsafeRange "Hijack")) Nothing Nothing) + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> extractMeetingEvents pushes `shouldBe` [] + + it "does not emit an event when deleteMeeting fails (non-creator)" $ do + result <- + runTestStack now gen (Map.singleton teamId [teamMember1, teamMember2]) teamConfig $ do + meeting <- createMeeting zUser1 (ConnId "test-conn") newMeeting + put @[Push] [] + _ <- deleteMeeting zUser2 (ConnId "test-conn") meeting.meeting.id + get @[Push] + + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> extractMeetingEvents pushes `shouldBe` [] + + it "delivers lifecycle events to all members and propagates conn" $ do + let members = + [ newMember uid1, + newMember uid2 + ] + meetingId = Id $ read "00000000-0000-0000-0000-000000000070" + convId = Id $ read "00000000-0000-0000-0000-000000000071" + qMeetingId = Qualified meetingId (Domain "wire.com") + qConvId = Qualified convId (Domain "wire.com") + originConn = ConnId "origin" + result <- + runTestStack now gen Map.empty def $ do + _ <- notifyMeetingEvent zUser1 (Just originConn) members qConvId Nothing MeetingEvent.Create qMeetingId + get @[Push] + case result of + Left err -> fail $ "Error: " <> show err + Right pushes -> do + let push = head pushes + -- Order is deterministic: 'members' is a literal list, 'map + -- localMemberToRecipient' preserves order, and mkMeetingEventPush + -- no longer filters or reorders recipients. + map (.recipientUserId) push.recipients `shouldBe` [uid1, uid2] + push.conn `shouldBe` Just originConn + + describe "V16 operations" $ do + let now = UTCTime (fromGregorian 2026 1 1) 0 + gen = mkStdGen 42 + uid = Id $ read "00000000-0000-0000-0000-000000000001" + zUser = toLocalUnsafe (Domain "wire.com") uid + startT = addUTCTime 3600 now + endT = addUTCTime 7200 now + newLegacy = + API.NewMeetingV16 + { startTime = startT, + endTime = endT, + recurrence = Nothing, + title = fromJust $ checked "Legacy Meeting", + invitedEmails = [] + } + + it "createMeetingV16 then getMeetingV16 returns the requested end_time" $ do + result <- + runTestStack now gen Map.empty def $ do + mwc <- createMeetingV16 zUser (ConnId "test-conn") newLegacy + getMeetingV16 zUser mwc.meeting.id + case result of + Left err -> fail $ "Error: " <> show err + Right (Just lm) -> lm.endTime `shouldBe` endT + Right Nothing -> fail "expected a meeting" + + it "a meeting created via the new shape is visible to legacy clients with the equivalent end_time" $ do + let nm = + API.NewMeeting + { title = fromJust $ checked "New Meeting", + startTime = startT, + endTime = addUTCTime 3600 startT, + tzid = API.defaultLegacyTimeZone, + recurrence = Nothing, + invitedEmails = [] + } + result <- + runTestStack now gen Map.empty def $ do + mwc <- createMeeting zUser (ConnId "test-conn") nm + getMeetingV16 zUser mwc.meeting.id + case result of + Left err -> fail $ "Error: " <> show err + Right (Just lm) -> lm.endTime `shouldBe` endT + Right Nothing -> fail "expected a meeting" + + it "a meeting created via the legacy shape is visible to new clients with end_time and the default tzid" $ do + result <- + runTestStack now gen Map.empty def $ do + mwc <- createMeetingV16 zUser (ConnId "test-conn") newLegacy + getMeeting zUser mwc.meeting.id + case result of + Left err -> fail $ "Error: " <> show err + Right (Just m) -> do + m.endTime `shouldBe` endT + m.tzid `shouldBe` API.defaultLegacyTimeZone + Right Nothing -> fail "expected a meeting" + + it "updateMeetingV16 moving both start and end keeps the requested end_time" $ do + let newStart = addUTCTime 8000 now + newEnd = addUTCTime 9000 now + upd = + API.UpdateMeeting + { startTime = Just newStart, + endTime = Just newEnd, + title = Nothing, + recurrence = Nothing, + tzid = Nothing + } + result <- + runTestStack now gen Map.empty def $ do + mwc <- createMeetingV16 zUser (ConnId "test-conn") newLegacy + updateMeetingV16 zUser (ConnId "test-conn") mwc.meeting.id upd + case result of + Left err -> fail $ "Error: " <> show err + Right (Just mwc') -> mwc'.meeting.endTime `shouldBe` newEnd + Right Nothing -> fail "expected the update to apply" + + describe "StoredMeeting postgres unmarshall" $ do + -- A backfilled row (tzid NOT NULL, end_time set): end_time is the truth + -- and tzid is read straight off the column. + it "parses end_time and tzid for a backfilled row" $ do + let t0 = UTCTime (fromGregorian 2026 1 1) 0 + t1 = addUTCTime 3600 t0 + legacyRow :: Store.StoredMeetingTuple + legacyRow = + ( nil, + "t", + nil, + t0, + t1, + "Europe/Berlin", + Nothing, + Nothing, + Nothing, + nil, + V.empty, + False, + t0, + t0 + ) + let result :: Either Text Store.StoredMeeting + result = postgresUnmarshall legacyRow + case result of + Left e -> expectationFailure $ "unmarshall failed: " <> show e + Right sm -> do + sm.endTime `shouldBe` t1 + sm.tzid `shouldBe` API.defaultLegacyTimeZone + + it "rejects a non-null but unparseable tzid instead of swallowing it" $ do + let t0 = UTCTime (fromGregorian 2026 1 1) 0 + t1 = addUTCTime 3600 t0 + badTzRow :: Store.StoredMeetingTuple + badTzRow = + ( nil, + "t", + nil, + t0, + t1, + "not-a-zone", + Nothing, + Nothing, + Nothing, + nil, + V.empty, + False, + t0, + t0 + ) + let result :: Either Text Store.StoredMeeting + result = postgresUnmarshall badTzRow + case result of + Left msg -> msg `shouldBe` ("invalid tzid" :: Text) + Right _ -> expectationFailure "expected unmarshall to reject an invalid tzid" + +-- | Synchronize with 'Wire.MeetingsSubsystem.Interpreter.startTimeTolerance' +expectedStartTimeTolerance :: NominalDiffTime +expectedStartTimeTolerance = 60 + +-- | Past-edit window configured in 'runTestStack'. +configuredPastEditPeriod :: NominalDiffTime +configuredPastEditPeriod = 3600 + +-- | Validity window, beyond this one-time meeting belong to the past +validityWindow :: NominalDiffTime +validityWindow = 11000 diff --git a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs index 88624c7019f..1324d919db3 100644 --- a/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs +++ b/libs/wire-subsystems/test/unit/Wire/MiniBackend.hs @@ -120,6 +120,7 @@ import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore import Wire.InternalEvent hiding (DeleteUser) import Wire.InvitationStore +import Wire.MlsKeyPackageSubsystem import Wire.MockInterpreters import Wire.NotificationSubsystem import Wire.PasswordResetCodeStore @@ -275,6 +276,7 @@ type MiniBackendLowerEffects = GalleyAPIAccess, SparAPIAccess, ClientStore, + MlsKeyPackageSubsystem, InvitationStore, PasswordStore, ActivationCodeStore, @@ -348,6 +350,7 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = . inMemoryActivationCodeStoreInterpreter . runInMemoryPasswordStoreInterpreter . inMemoryInvitationStoreInterpreter + . mockMlsKeyPackageSubsystem . runInMemoryClientStoreInterpreter . miniSparAPIAccess . miniGalleyAPIAccess teams galleyConfigs @@ -378,12 +381,18 @@ miniBackendLowerEffectsInterpreters mb@(MiniBackendParams {..}) = mockConversationSubsystem :: forall r'. InterpreterFor ConversationSubsystem r' mockConversationSubsystem = interpretH $ \case _ -> error "Unimplemented ConversationSubsystem operation in mock" + mockMlsKeyPackageSubsystem :: forall r'. InterpreterFor MlsKeyPackageSubsystem r' + mockMlsKeyPackageSubsystem = interpret $ \case + HasMlsKeyPackages {} -> pure False + HasMlsKeyPackagesBulk {} -> pure mempty + _ -> error "Unimplemented MlsKeyPackageSubsystem operation in mock" type StateEffects = '[ State [Push], State (Map (TeamId) [TeamCollaborator]), State (Map (TeamId, InvitationId) StoredInvitation), State (Map InvitationCode StoredInvitation), + State (Map (TeamId, EmailAddress) [UserId]), State (Map EmailKey (Maybe UserId, ActivationCode)), State [EmailKey], State [StoredUser], @@ -414,6 +423,7 @@ stateEffectsInterpreters MiniBackendParams {..} = . liftUserStoreState . liftBlockListStoreState . liftActivationCodeStoreState + . liftPendingScimUserStoreState . liftInvitationInfoStoreState . liftInvitationStoreState . liftTeamCollaboratorsStoreState @@ -507,6 +517,7 @@ data MiniBackend = MkMiniBackend activationCodes :: Map EmailKey (Maybe UserId, ActivationCode), invitationInfos :: Map InvitationCode StoredInvitation, invitations :: Map (TeamId, InvitationId) StoredInvitation, + pendingScimUsers :: Map (TeamId, EmailAddress) [UserId], teamIdps :: Map TeamId IdPList, teamCollaborators :: Map TeamId [TeamCollaborator], pushNotifications :: [Push], @@ -527,6 +538,7 @@ instance Default MiniBackend where activationCodes = mempty, invitationInfos = mempty, invitations = mempty, + pendingScimUsers = mempty, teamIdps = mempty, teamCollaborators = mempty, pushNotifications = mempty, @@ -810,6 +822,11 @@ liftInvitationStoreState = interpret \case Polysemy.State.Get -> gets (.invitations) Put newInvs -> modify $ \b -> b {invitations = newInvs} +liftPendingScimUserStoreState :: (Member (State MiniBackend) r) => Sem (State (Map (TeamId, EmailAddress) [UserId]) : r) a -> Sem r a +liftPendingScimUserStoreState = interpret \case + Polysemy.State.Get -> gets (.pendingScimUsers) + Put newUsers -> modify $ \b -> b {pendingScimUsers = newUsers} + liftTeamCollaboratorsStoreState :: (Member (State MiniBackend) r) => Sem (State (Map TeamId [TeamCollaborator]) : r) a -> Sem r a liftTeamCollaboratorsStoreState = interpret \case Polysemy.State.Get -> gets (.teamCollaborators) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs index c1bdcdb2628..4630c0c7f77 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters.hs @@ -26,6 +26,7 @@ import Wire.MockInterpreters.AuthenticationSubsystem as MockInterpreters import Wire.MockInterpreters.BackgroundJobPublisher as MockInterpreters import Wire.MockInterpreters.BlockListStore as MockInterpreters import Wire.MockInterpreters.ClientStore as MockInterpreters +import Wire.MockInterpreters.ConversationStore as MockInterpreters import Wire.MockInterpreters.ConversationSubsystem as MockInterpreters import Wire.MockInterpreters.CryptoSign as MockInterpreters import Wire.MockInterpreters.DomainRegistrationStore as MockInterpreters diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs index dfb21478e8c..7250d3047fa 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ActivationCodeStore.hs @@ -42,14 +42,16 @@ emailKeyToCode = inMemoryActivationCodeStoreInterpreter :: (Member (State (Map EmailKey (Maybe UserId, ActivationCode))) r) => InterpreterFor ActivationCodeStore r -inMemoryActivationCodeStoreInterpreter = interpret \case - LookupActivationCode ek -> gets (!? ek) - NewActivationCode ek _ uid -> do - let key = - ActivationKey - . Ascii.encodeBase64Url - . T.encodeUtf8 - . emailKeyUniq - $ ek - c = emailKeyToCode ek - modify (insert ek (uid, c)) $> Activation key c +inMemoryActivationCodeStoreInterpreter = + interpret \case + LookupActivationCode ek -> gets (!? ek) + NewActivationCode ek _ uid -> do + let key = + ActivationKey + . Ascii.encodeBase64Url + . T.encodeUtf8 + . emailKeyUniq + $ ek + c = emailKeyToCode ek + modify (insert ek (uid, c)) $> Activation key c + DeleteActivationCode ek -> modify (delete ek) diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs index 1873f17a118..dc87708e24a 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/AuthenticationSubsystem.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.MockInterpreters.AuthenticationSubsystem where import Imports diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs index ba7824f4440..8594a28f2b7 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/BackgroundJobPublisher.hs @@ -19,8 +19,8 @@ module Wire.MockInterpreters.BackgroundJobPublisher where import Imports import Polysemy -import Wire.BackgroundJobsPublisher (BackgroundJobsPublisher (..)) +import Wire.BackgroundJobsPublisher (BackgroundJobPublisher (..)) -noopBackgroundJobsPublisher :: InterpreterFor BackgroundJobsPublisher r -noopBackgroundJobsPublisher = interpret $ \case +noopBackgroundJobPublisher :: InterpreterFor BackgroundJobPublisher r +noopBackgroundJobPublisher = interpret $ \case PublishJob {} -> pure () diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs index 5c6918bfd9f..c31f18e3976 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ClientStore.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.MockInterpreters.ClientStore where import Data.ByteString.Lazy qualified as LBS @@ -91,6 +108,13 @@ handleClientStore = \case ] LookupPrekeyIds uid cid -> gets $ maybe [] (map (.prekeyId) . (.prekeys)) . Map.lookup cid . Map.findWithDefault mempty uid . (.byUser) + LookupPrekeyPresenceBulk pairs -> + gets $ \st -> + Set.fromList + [ (uid, cid) + | (uid, cid) <- pairs, + maybe False (not . null . (.prekeys)) (Map.lookup cid (Map.findWithDefault mempty uid st.byUser)) + ] GetActivityTimestamps uid -> gets $ map (.client.clientLastActive) . Map.elems . Map.findWithDefault mempty uid . (.byUser) UpdatePrekeys uid cid prekeys -> diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ConversationStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ConversationStore.hs new file mode 100644 index 00000000000..166b45c14d7 --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/ConversationStore.hs @@ -0,0 +1,37 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it +-- under the terms of the GNU Affero General Public License as published by the +-- Free Software Foundation, either version 3 of the License, or (at your +-- option) any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +module Wire.MockInterpreters.ConversationStore where + +import Data.Id (ConvId, UserId) +import Data.Map qualified as Map +import Data.Qualified (Qualified) +import Imports +import Polysemy +import Polysemy.State +import Wire.ConversationStore (ConversationStore (..)) +import Wire.StoredConversation (StoredConversation) + +inMemoryConversationStoreInterpreter :: + (Member (State [Qualified UserId]) r) => + Map.Map ConvId StoredConversation -> + InterpreterFor ConversationStore r +inMemoryConversationStoreInterpreter store = + interpret $ \case + GetConversation cid -> pure (Map.lookup cid store) + SetOtherMember _ target _ -> modify @[(Qualified UserId)] (<> [target]) + _ -> error "ConversationStore: not implemented in mock" diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs index 0f7b6f7aec7..7bc540116f4 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/InvitationStore.hs @@ -17,19 +17,20 @@ module Wire.MockInterpreters.InvitationStore where -import Data.Id (InvitationId, TeamId) +import Data.Id (InvitationId, TeamId, UserId) import Data.Map (alter, elems, (!?)) import Data.Map qualified as M import Imports hiding ((!?)) import Polysemy import Polysemy.State (State, get, gets, modify) -import Wire.API.User (InvitationCode (..)) +import Wire.API.User (EmailAddress, InvitationCode (..)) import Wire.InvitationStore inMemoryInvitationStoreInterpreter :: forall r. ( Member (State (Map (TeamId, InvitationId) StoredInvitation)) r, - Member (State (Map (InvitationCode) StoredInvitation)) r + Member (State (Map (InvitationCode) StoredInvitation)) r, + Member (State (Map (TeamId, EmailAddress) [UserId])) r ) => InterpreterFor InvitationStore r inMemoryInvitationStoreInterpreter = interpret \case @@ -49,7 +50,23 @@ inMemoryInvitationStoreInterpreter = interpret \case LookupInvitationsByEmail em -> let c i = guard (i.email == em) $> i in mapMaybe c . elems <$> get @(Map (TeamId, InvitationId) _) + InsertPendingScimUser tid email uid -> + modify @(Map (TeamId, EmailAddress) [UserId]) (M.insertWith (++) (tid, email) [uid]) + LookupPendingScimUsers tid email -> + gets @(Map (TeamId, EmailAddress) [UserId]) (fromMaybe [] . (!? (tid, email))) + DeletePendingScimUser tid email uid -> + modify @(Map (TeamId, EmailAddress) [UserId]) $ + M.alter + ( \case + Nothing -> Nothing + Just uids -> case filter (/= uid) uids of + [] -> Nothing + remaining -> Just remaining + ) + (tid, email) LookupInvitationsPaginated {} -> error "LookupInvitationsPaginated" - CountInvitations tid -> gets (fromIntegral . M.size . M.filterWithKey (\(tid', _) _v -> tid == tid')) + CountInvitations tid -> + gets @(Map (TeamId, InvitationId) StoredInvitation) + (fromIntegral . M.size . M.filterWithKey (\(tid', _) _v -> tid == tid')) DeleteInvitation _tid _invId -> error "DeleteInvitation" DeleteAllTeamInvitations _tid -> error "DeleteAllTeamInvitations" diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs index fde676442c4..c54f2de3b3f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/MeetingsStore.hs @@ -33,7 +33,7 @@ inMemoryMeetingsStoreInterpreter :: (Member (State (Map MeetingId StoredMeeting)) r, Member Now r, Member Random r) => InterpreterFor MeetingsStore r inMemoryMeetingsStoreInterpreter = interpret $ \case - CreateMeeting title creator startTime endTime recurrence conversationId invitedEmails trial -> do + CreateMeeting title creator startTime endTime tzid recurrence conversationId invitedEmails trial -> do mid <- Random.newId now <- Now.get let sm = @@ -43,6 +43,7 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case creator = creator, startTime = startTime, endTime = endTime, + tzid = tzid, recurrence = recurrence, conversationId = conversationId, invitedEmails = invitedEmails, @@ -53,7 +54,7 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case modify (Map.insert mid sm) pure sm GetMeeting mid -> gets (Map.lookup mid) - UpdateMeeting mid title startTime endTime recurrence -> do + UpdateMeeting mid title startTime endTime tzid recurrence -> do sm <- gets (Map.lookup mid) case sm of Nothing -> pure Nothing @@ -62,11 +63,14 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case let updatedMeeting = meeting { title = fromMaybe (meeting.title) title, - startTime = fromMaybe meeting.startTime startTime, + startTime = startTime', endTime = fromMaybe meeting.endTime endTime, + tzid = fromMaybe meeting.tzid tzid, recurrence = fromMaybe meeting.recurrence recurrence, updatedAt = now } + where + startTime' = fromMaybe meeting.startTime startTime modify (Map.insert mid updatedMeeting) >> pure (Just updatedMeeting) ListMeetingsByUser userId cutoffTime -> gets $ @@ -120,6 +124,6 @@ inMemoryMeetingsStoreInterpreter = interpret $ \case GetOldMeetings cutoffTime batchSize -> gets $ take batchSize - . List.sortOn (.endTime) + . List.sortOn effectiveEndTime . filter (\sm -> maybe False (< cutoffTime) (effectiveEndTime sm)) . Map.elems diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs index 9f6d24d9ef1..d30c8546589 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserStore.hs @@ -51,7 +51,16 @@ inMemoryUserStoreInterpreter :: Member (State (Map UserId Password)) r ) => InterpreterFor UserStore r -inMemoryUserStoreInterpreter = interpret $ \case +inMemoryUserStoreInterpreter = inMemoryUserStoreInterpreterWithDeleteHook (const $ pure ()) + +inMemoryUserStoreInterpreterWithDeleteHook :: + forall r. + ( Member (State [StoredUser]) r, + Member (State (Map UserId Password)) r + ) => + (UserId -> Sem r ()) -> + InterpreterFor UserStore r +inMemoryUserStoreInterpreterWithDeleteHook onDelete = interpret $ \case CreateUser new _ -> do modify (newStoredUserToStoredUser new :) forM_ new.password $ modify . Map.insert new.id @@ -127,7 +136,9 @@ inMemoryUserStoreInterpreter = interpret $ \case us <- get us' <- f us put us' - DeleteUser user -> modify @[StoredUser] $ filter (\u -> u.id /= User.userId user) + DeleteUser user -> do + onDelete (User.userId user) + modify @[StoredUser] $ filter (\u -> u.id /= User.userId user) LookupName uid -> (.name) <$$> gets @[StoredUser] (find $ \u -> u.id == uid) LookupHandle h -> lookupHandleImpl h GlimpseHandle h -> lookupHandleImpl h @@ -139,7 +150,6 @@ inMemoryUserStoreInterpreter = interpret $ \case map (\u -> if u.id == uid then u {teamId = Just tid} :: StoredUser else u) GetRichInfo _ -> error "GetRichInfo: not implemented" - LookupRichInfos _ -> error "LookupRichInfos: not implemented" UpdateRichInfo {} -> error "UpdateRichInfo: Not implemented" UpsertHashedPassword uid pw -> modify $ Map.insert uid pw diff --git a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs index c5feaeae032..786f733240f 100644 --- a/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs +++ b/libs/wire-subsystems/test/unit/Wire/MockInterpreters/UserSubsystem.hs @@ -68,13 +68,14 @@ inMemoryUserSubsystemInterpreter = GetLocalUserProfiles luids -> toProfile . mkUserFromStored testDomain testLocale <$$> UserStore.getUsers (tUnqualified luids) - GetLocalAppProfiles _ -> + GetLocalAppProfiles _ _ -> error "GetLocalAppProfiles: implement on demand (userSubsystemInterpreter)" GetAccountsBy (tUnqualified -> GetBy NoPendingInvitations True True uids []) -> mkUserFromStored testDomain testLocale <$$> UserStore.getUsers uids GetAccountsBy (tUnqualified -> GetBy _ _ _ uids []) -> mkUserFromStored testDomain testLocale <$$> UserStore.getUsers uids GetAccountsBy _ -> error "GetAccountsBy: implement on demand (userSubsystemInterpreter)" + IsUsersContactable users _ _ -> pure (False <$ users) UpdateUserProfile {} -> error "UpdateUserProfile: implement on demand (userSubsystemInterpreter)" CheckHandle _ -> error "CheckHandle: implement on demand (userSubsystemInterpreter)" CheckHandles _ _ -> error "CheckHandles: implement on demand (userSubsystemInterpreter)" diff --git a/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs index a069501934f..e021cc7e119 100644 --- a/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/NotificationSubsystem/InterpreterSpec.hs @@ -236,6 +236,34 @@ spec = describe "NotificationSubsystem.Interpreter" do map fst logs `shouldBe` [Error] cs (head (map snd logs)) `shouldContain` "error=TestException" + describe "pushBestEffort" do + it "logs errors without failing the caller" do + let mockConfig = + NotificationSubsystemConfig + { fanoutLimit = toRange $ Proxy @30, + chunkSize = 12, + slowPushDelay = 1, + requestId = RequestId defRequestId + } + + user1 <- generate arbitrary + payload1 <- generate $ resize 1 arbitrary + clients1 <- generate $ resize 3 arbitrary + let push1 = + def + { transient = True, + route = V2.RouteDirect, + recipients = [Recipient user1 (V2.RecipientClientsSome clients1)], + json = payload1 + } + (_, attemptedPushes, logs) <- + runMiniStackAsync mockConfig $ + pushBestEffort push1 + + attemptedPushes `shouldBe` [[toV2Push push1]] + map fst logs `shouldBe` [Error] + cs (head (map snd logs)) `shouldContain` "error=TestException" + describe "toV2Push" do it "does the transformation correctly" $ property \(pushToUser :: Push) -> let v2Push = toV2Push pushToUser diff --git a/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs index fba4f321bd2..5d8363dad4f 100644 --- a/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/SAMLEmailSubsystem/InterpreterSpec.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Wire.SAMLEmailSubsystem.InterpreterSpec (spec) where import Data.Default @@ -39,6 +56,7 @@ import Wire.EmailSending import Wire.EmailSubsystem qualified as Email import Wire.EmailSubsystem.Interpreter import Wire.EmailSubsystem.Template +import Wire.EmailSubsystem.TemplateFixtures import Wire.EmailSubsystem.Templates.Team import Wire.GalleyAPIAccess import Wire.MockInterpreters @@ -76,32 +94,9 @@ spec = do flip zip ((replicate 5 enTextParts) ++ (replicate 2 deTextParts)) $ parseLocalUnsafe <$> ["en", "en-EN", "en-GB", "es", "es-ES", "de", "de_DE"] parseLocalUnsafe = fromMaybe (error "Unknown locale") . parseLocale - teamOpts = - TeamOpts - { tInvitationUrl = "https://example.com/join/?team-code=${code}", - tExistingUserInvitationUrl = "https://example.com/accept-invitation/?team-code=${code}", - tActivationUrl = "https://example.com/verify/?key=${key}&code=${code}", - tCreatorWelcomeUrl = "https://example.com/creator-welcome-website", - tMemberWelcomeUrl = "https://example.com/member-welcome-website" - } - defLocale = Locale ((fromJust . parseLanguage) "en") Nothing - emailSender = unsafeEmailAddress "wire" "example.com" - branding = - Map.fromList - [ ("brand", "Wire Test"), - ("brand_url", "https://wire.example.com"), - ("brand_label_url", "wire.example.com"), - ("brand_logo", "https://wire.example.com/p/img/email/logo-email-black.png"), - ("brand_service", "Wire Service Provider"), - ("copyright", "© WIRE SWISS GmbH"), - ("misuse", "misuse@wire.example.com"), - ("legal", "https://wire.example.com/legal/"), - ("forgot", "https://wire.example.com/forgot/"), - ("support", "https://support.wire.com/") - ] -- Run duplicated IO tasks here to save some time - teamTemplates :: Localised TeamTemplates <- runIO $ loadTeamTemplates teamOpts "templates" defLocale emailSender + teamTemplates :: Localised TeamTemplates <- runIO loadTestTeamTemplates newCerts <- runIO $ X509.readCertificates "test/resources/saml/certs.store" describe "SendSAMLIdPChanged" $ do @@ -118,7 +113,7 @@ spec = do storedUser' = patchStoredUser storedUser teamId userLocale uid notif = IdPCreated (Just uid) idp' - (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do + (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do sendSAMLIdPChanged notif assertNoWarnLogs logs @@ -134,7 +129,7 @@ spec = do let idp' = patchIdP idp teamId storedUser' = patchStoredUser storedUser teamId userLocale uid notif = IdPDeleted uid idp' - (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do + (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do sendSAMLIdPChanged notif assertNoWarnLogs logs @@ -167,7 +162,7 @@ spec = do ) storedUser' = patchStoredUser storedUser teamId userLocale uid notif = IdPUpdated uid idpOld' idpNew' - (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do + (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do sendSAMLIdPChanged notif assertNoWarnLogs logs @@ -186,7 +181,7 @@ spec = do teamMember :: TeamMember = mkTeamMember uid (rolePermissions role) Nothing UserLegalHoldDisabled teamMap :: Map TeamId [TeamMember] = Map.singleton teamId [teamMember] - (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do + (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do sendSAMLIdPChanged notif assertNoWarnLogs logs @@ -201,7 +196,7 @@ spec = do teamMember :: TeamMember = mkTeamMember uid (rolePermissions role) Nothing UserLegalHoldDisabled teamMap :: Map TeamId [TeamMember] = Map.singleton teamId [teamMember] - (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates branding $ do + (mails, logs, _res) <- runInterpreters [storedUser'] teamMap teamTemplates $ do sendSAMLIdPChanged notif assertNoWarnLogs logs @@ -220,7 +215,7 @@ spec = do ) users - (mails, logs, _res) <- runInterpreters (fst <$> users) teamMap teamTemplates branding $ do + (mails, logs, _res) <- runInterpreters (fst <$> users) teamMap teamTemplates $ do sendSAMLIdPChanged notif assertNoWarnLogs logs @@ -339,7 +334,6 @@ runInterpreters :: [StoredUser] -> Map TeamId [TeamMember] -> Localised TeamTemplates -> - Map Text Text -> Sem '[ SAMLEmailSubsystem, TeamSubsystem, @@ -357,7 +351,7 @@ runInterpreters :: ] a -> IO ([Mail], [(Level, LByteString)], a) -runInterpreters users teamMap teamTemplates branding action = do +runInterpreters users teamMap teamTemplates action = do lr <- newLogRecorder (mails, res) <- runM diff --git a/libs/wire-subsystems/test/unit/Wire/ScimExternalIdStore/MemSpec.hs b/libs/wire-subsystems/test/unit/Wire/ScimExternalIdStore/MemSpec.hs new file mode 100644 index 00000000000..d08c101798f --- /dev/null +++ b/libs/wire-subsystems/test/unit/Wire/ScimExternalIdStore/MemSpec.hs @@ -0,0 +1,341 @@ +{-# LANGUAGE QuantifiedConstraints #-} +{-# OPTIONS_GHC -Wno-orphans #-} +{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.ScimExternalIdStore.MemSpec (spec) where + +import Data.Id +import Imports +import Polysemy +import Polysemy.Check +import Test.Hspec +import Test.Hspec.QuickCheck +import Test.QuickCheck +import Wire.API.User.Scim (ScimUserCreationStatus, ValidScimId) +import Wire.ScimExternalIdStore qualified as E +import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) + +spec :: Spec +spec = + modifyMaxSuccess (const 1000) $ + propsForInterpreter "scimExternalIdStoreToMem" snd $ + pure . run . scimExternalIdStoreToMem + +-- 'CoArbitrary' is required by 'Polysemy.Check' for the argument types of the +-- effect's operations. These instances are orphans here (neither the type nor +-- the class is defined in this package); they mirror +-- @services/spar/test/Arbitrary.hs@. Blank instances resolve via QuickCheck's +-- generic default; both types derive 'Generic' ('UserId' in types-common, +-- 'ScimUserCreationStatus' in @Wire.API.User.Scim@). Being in scope here, they +-- also discharge the matching 'PropConstraints' superclasses, so the instance +-- head below omits them (stating them would trip @-Wredundant-constraints@). +instance CoArbitrary UserId + +instance CoArbitrary ScimUserCreationStatus + +propsForInterpreter :: + (PropConstraints r f) => + String -> + (forall a. f a -> a) -> + (forall a. Sem r a -> IO (f a)) -> + Spec +propsForInterpreter interpreter extract lower = do + describe interpreter $ do + prop "delete/delete" $ prop_deleteDelete Nothing lower + prop "delete/lookup" $ prop_deleteLookup (Just $ show . void . extract) lower + prop "delete/insert" $ prop_deleteInsert Nothing lower + prop "lookup/insert" $ prop_lookupInsert Nothing lower + prop "insert/delete" $ prop_insertDelete Nothing lower + prop "insert/lookup" $ prop_insertLookup (Just $ show . void . extract) lower + prop "insert/insert" $ prop_insertInsert (Just $ show . void . extract) lower + prop "insertStatus/lookupStatus" $ prop_insertStatusLookupStatus (Just $ show . void . extract) lower + prop "insertStatus/insertStatus" $ prop_insertStatusInsertStatus (Just $ show . void . extract) lower + prop "lookupStatus/insertStatus" $ prop_lookupStatusInsertStatus Nothing lower + +-- | All the constraints we need to generalize properties in this module. +-- A regular type synonym doesn't work due to dreaded impredicative +-- polymorphism. +class + (Arbitrary UserId, CoArbitrary UserId, Arbitrary ValidScimId, Arbitrary ScimUserCreationStatus, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) => + PropConstraints r f + +instance + (Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) => + PropConstraints r f + +-- | Adapt the fully-polymorphic interpreter to the rank-2 position 'prepropLaw' +-- expects. 'prepropLaw' wants @forall z. Sem r (a, z) -> IO (f (a, z))@ for the +-- law's result type @a@, while callers hand us @forall x. Sem r x -> IO (f x)@. +-- Passing @lower@ directly trips GHC's shallow subsumption under this package's +-- extension set (GHC2021); the explicit eta-expansion forces instantiation at +-- the application site and is always safe. The sibling specs still in +-- @services/spar@ (Haskell2010) pass the interpreter point-free. +lowerAsLaw :: + (forall x. Sem r x -> IO (f x)) -> + (forall z. Sem r (a, z) -> IO (f (a, z))) +lowerAsLaw lower sem = lower sem + +prop_insertLookup :: + (PropConstraints r f) => + Maybe (f (Maybe UserId) -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_insertLookup shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + email <- arbitrary + uid <- arbitrary + pure $ + simpleLaw + ( do + E.insert tid email uid + E.lookup tid email + ) + ( do + E.insert tid email uid + pure (Just uid) + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_lookupInsert :: + (PropConstraints r f) => + Maybe (f () -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_lookupInsert shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + email <- arbitrary + pure $ + simpleLaw + ( do + E.lookup tid email >>= maybe (pure ()) (E.insert tid email) + ) + ( do + pure () + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_insertDelete :: + (PropConstraints r f) => + Maybe (f () -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_insertDelete shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + email <- arbitrary + uid <- arbitrary + pure $ + simpleLaw + ( do + E.insert tid email uid + E.delete tid email + ) + ( do + E.delete tid email + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_deleteInsert :: + (PropConstraints r f) => + Maybe (f () -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_deleteInsert shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + email <- arbitrary + uid <- arbitrary + pure $ + simpleLaw + ( do + E.delete tid email + E.insert tid email uid + ) + ( do + E.insert tid email uid + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_insertInsert :: + (PropConstraints r f) => + Maybe (f (Maybe UserId) -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_insertInsert shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + email <- arbitrary + uid <- arbitrary + uid' <- arbitrary + pure $ + simpleLaw + ( do + E.insert tid email uid + E.insert tid email uid' + E.lookup tid email + ) + ( do + E.insert tid email uid' + E.lookup tid email + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_insertStatusLookupStatus :: + (PropConstraints r f) => + Maybe (f (Maybe (UserId, ScimUserCreationStatus)) -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_insertStatusLookupStatus shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + veid <- arbitrary + uid <- arbitrary + status <- arbitrary + pure $ + simpleLaw + ( do + E.insertStatus tid veid uid status + E.lookupStatus tid veid + ) + ( do + E.insertStatus tid veid uid status + pure (Just (uid, status)) + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_insertStatusInsertStatus :: + (PropConstraints r f) => + Maybe (f (Maybe (UserId, ScimUserCreationStatus)) -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_insertStatusInsertStatus shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + veid <- arbitrary + uid1 <- arbitrary + status1 <- arbitrary + uid2 <- arbitrary + status2 <- arbitrary + pure $ + simpleLaw + ( do + E.insertStatus tid veid uid1 status1 + E.insertStatus tid veid uid2 status2 + E.lookupStatus tid veid + ) + ( do + E.insertStatus tid veid uid2 status2 + pure (Just (uid2, status2)) + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_lookupStatusInsertStatus :: + (PropConstraints r f) => + Maybe (f () -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_lookupStatusInsertStatus shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + veid <- arbitrary + pure $ + simpleLaw + ( do + E.lookupStatus tid veid >>= maybe (pure ()) (uncurry (E.insertStatus tid veid)) + ) + ( do + pure () + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_deleteDelete :: + (PropConstraints r f) => + Maybe (f () -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_deleteDelete shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + email <- arbitrary + pure $ + simpleLaw + ( do + E.delete tid email + E.delete tid email + ) + ( do + E.delete tid email + ) + ) + shrinkFn + (lowerAsLaw lower) + +prop_deleteLookup :: + (PropConstraints r f) => + Maybe (f (Maybe UserId) -> String) -> + (forall a. Sem r a -> IO (f a)) -> + Property +prop_deleteLookup shrinkFn lower = + prepropLaw @'[E.ScimExternalIdStore] + ( do + tid <- arbitrary + email <- arbitrary + uid <- arbitrary + pure $ + Law + { lawLhs = do + E.delete tid email + E.lookup tid email, + lawRhs = do + E.delete tid email + pure Nothing, + lawPrelude = [E.insert tid email uid], + lawPostlude = [] @(Sem _ ()) + } + ) + shrinkFn + (lowerAsLaw lower) diff --git a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs index ce135460d02..ef2f85d7657 100644 --- a/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/TeamInvitationSubsystem/InterpreterSpec.hs @@ -41,9 +41,11 @@ import Test.QuickCheck import Wire.API.EnterpriseLogin import Wire.API.Error (ErrorS) import Wire.API.Error.Galley (GalleyError (TeamMemberNotFound, TeamNotFound)) +import Wire.API.Password (Password) import Wire.API.Team.Invitation import Wire.API.Team.Member import Wire.API.Team.Permission +import Wire.API.Team.Role (defaultRole) import Wire.API.User import Wire.EmailSubsystem import Wire.EnterpriseLoginSubsystem @@ -61,6 +63,7 @@ import Wire.TeamSubsystem import Wire.TeamSubsystem.GalleyAPI import Wire.UserKeyStore import Wire.UserStore (UserStore) +import Wire.UserStore qualified as UserStore import Wire.UserSubsystem import Wire.Util @@ -75,6 +78,7 @@ type AllEffects = UserKeyStore, State (Map (TeamId, InvitationId) StoredInvitation), State (Map (InvitationCode) StoredInvitation), + State (Map (TeamId, EmailAddress) [UserId]), Now, State UTCTime, Error TeamInvitationSubsystemError, @@ -84,6 +88,7 @@ type AllEffects = State (Map EmailAddress [SentMail]), UserSubsystem, UserStore, + State [UserId], UserKeyStore ] @@ -94,11 +99,42 @@ data RunAllEffectsArgs = RunAllEffectsArgs } deriving (Eq, Show) +data InviteScenarioObservation = InviteScenarioObservation + { -- 'Nothing' means the manual invitation was created successfully. + invitationResult :: Maybe TeamInvitationSubsystemError, + -- User IDs passed to 'UserStore.DeleteUser' during reconciliation. + deletedUserIds :: [UserId], + -- The candidate user's record after reconciliation, if it still exists. + observedUser :: Maybe StoredUser, + -- User IDs still present in the pending SCIM index after reconciliation. + observedPendingScimUsers :: [UserId] + } + deriving (Eq, Show) + +data InviteScenarioInput = InviteScenarioInput + { invitationTeam :: TeamId, + inviter :: StoredUser, + otherUsers :: [StoredUser], + pendingScimUsers :: [(TeamId, EmailAddress, UserId)], + liveInvitations :: [InsertInvitation], + inviteeEmail :: EmailAddress, + observedUid :: UserId + } + deriving (Eq, Show) + runAllEffects :: RunAllEffectsArgs -> Sem AllEffects a -> Either LocalErrors a -runAllEffects args = +runAllEffects args = runAllEffectsWithUserKeys args.initialUsers args + +runAllEffectsWithUserKeys :: [StoredUser] -> RunAllEffectsArgs -> Sem AllEffects a -> Either LocalErrors a +runAllEffectsWithUserKeys initialUsers args = run - . runInMemoryUserKeyStoreIntepreterWithStoredUsers args.initialUsers - . runInMemoryUserStoreInterpreter args.initialUsers mempty + . runInMemoryUserKeyStoreIntepreterWithStoredUsers initialUsers + . evalState ([] :: [UserId]) + . evalState mempty + . evalState args.initialUsers + . inMemoryUserStoreInterpreterWithDeleteHook (\uid -> modify @[UserId] (uid :)) + . raiseUnder @(State [StoredUser]) + . raiseUnder @(State (Map UserId Password)) . inMemoryUserSubsystemInterpreter . evalState mempty . noopEmailSubsystemInterpreter @@ -107,6 +143,7 @@ runAllEffects args = . interpretNowAsState . evalState mempty . evalState mempty + . evalState mempty . (evalState mempty . inMemoryUserKeyStoreInterpreter . raiseUnder) . inMemoryInvitationStoreInterpreter . evalState (mkStdGen 3) @@ -116,6 +153,49 @@ runAllEffects args = . discardTinyLogs . enterpriseLoginSubsystemTestInterpreter args.constGuardResult +runInviteScenarioObserved :: + InviteScenarioInput -> + Either LocalErrors InviteScenarioObservation +runInviteScenarioObserved input = + runAllEffectsWithUserKeys [input.inviter] args . runTeamInvitationSubsystem config $ do + for_ input.liveInvitations $ \inv -> void $ insertInvitation inv 3_000_000 + for_ input.pendingScimUsers $ \(indexTeam, email, uid) -> + deleteKey (mkEmailKey email) >> insertPendingScimUser indexTeam email uid + result <- catch (inviteUser inviterLuid input.invitationTeam invitationRequest >> pure Nothing) (pure . Just) + deletedUsers <- get @[UserId] + observedUser <- UserStore.getUser input.observedUid + observedIndex <- lookupPendingScimUsers input.invitationTeam input.inviteeEmail + pure + InviteScenarioObservation + { invitationResult = result, + deletedUserIds = deletedUsers, + observedUser, + observedPendingScimUsers = observedIndex + } + where + inviterLuid = toLocalUnsafe testDomain input.inviter.id + inviterMember = mkTeamMember input.inviter.id fullPermissions Nothing UserLegalHoldDisabled + invitationRequest = + InvitationRequest + { locale = Nothing, + role = Nothing, + inviteeName = Nothing, + inviteeEmail = input.inviteeEmail, + allowExisting = False + } + config = + TeamInvitationSubsystemConfig + { maxTeamSize = 50, + teamInvitationTimeout = 3_000_000, + blockedDomains = HashSet.empty + } + args = + RunAllEffectsArgs + { teams = Map.singleton input.invitationTeam [inviterMember], + initialUsers = input.inviter : input.otherUsers, + constGuardResult = Nothing + } + data LocalErrors = ETeamMemberNotFound | ETeamNotFound @@ -139,7 +219,250 @@ runLocalErrors = fmap toLocalErrors . runError . runError . runError spec :: Spec spec = do describe "InviteUser" $ do - prop "honors dommain config from `brig.domain_registration`" $ + prop "rejects a manual invitation when a matching SCIM invitation is pending" $ + \(tid :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) + (code :: InvitationCode) -> + inviter0.id /= scimUser0.id ==> + let inviter :: StoredUser + inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } + + scimUser :: StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + emailUnvalidated = Nothing, + activated = False, + status = Just PendingInvitation, + teamId = Just tid, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } + + storedInvitation = + MkInsertInvitation + { invitationId = Id (toUUID scimUser.id), + teamId = tid, + role = defaultRole, + createdAt = defaultTime, + createdBy = Just inviter.id, + inviteeEmail = inviteeEmail, + inviteeName = Nothing, + code = code + } + + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(tid, inviteeEmail, scimUser.id)], + liveInvitations = [storedInvitation], + inviteeEmail, + observedUid = scimUser.id + } + in counterexample (show (inviter, scimUser, storedInvitation)) $ + outcome + === Right + InviteScenarioObservation + { invitationResult = Just TeamInvitationEmailTaken, + deletedUserIds = [], + observedUser = Just scimUser, + observedPendingScimUsers = [scimUser.id] + } + + prop "allows a manual invitation after a matching SCIM invitation expired" $ + \(tid :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + inviter0.id /= scimUser0.id ==> + let inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + activated = False, + status = Just PendingInvitation, + teamId = Just tid, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(tid, inviteeEmail, scimUser.id)], + liveInvitations = [], + inviteeEmail, + observedUid = scimUser.id + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Nothing, + deletedUserIds = [scimUser.id], + observedUser = Nothing, + observedPendingScimUsers = [] + } + + prop "rejects a manual invitation for an active SCIM account" $ + \(tid :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + inviter0.id /= scimUser0.id ==> + let inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(tid, inviteeEmail, scimUser.id)], + liveInvitations = [], + inviteeEmail, + observedUid = scimUser.id + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Just TeamInvitationEmailTaken, + deletedUserIds = [], + observedUser = Just scimUser, + observedPendingScimUsers = [scimUser.id] + } + + prop "allows a manual invitation when the SCIM index entry is stale" $ + \(tid :: TeamId) + (inviter :: StoredUser) + (staleUid :: UserId) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + inviter.id /= staleUid ==> + let preparedInviter = + inviter + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just tid, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = tid, + inviter = preparedInviter, + otherUsers = [], + pendingScimUsers = [(tid, inviteeEmail, staleUid)], + liveInvitations = [], + inviteeEmail, + observedUid = staleUid + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Nothing, + deletedUserIds = [], + observedUser = Nothing, + observedPendingScimUsers = [] + } + + prop "allows a manual invitation in another team despite a pending SCIM invitation" $ + \(scimTeam :: TeamId) + (manualTeam :: TeamId) + (inviter0 :: StoredUser) + (scimUser0 :: StoredUser) + (inviterEmail :: EmailAddress) + (inviteeEmail :: EmailAddress) -> + scimTeam /= manualTeam && inviter0.id /= scimUser0.id ==> + let inviter = + inviter0 + { email = Just inviterEmail, + activated = True, + status = Just Active, + teamId = Just manualTeam, + managedBy = Just ManagedByWire, + userType = Just UserTypeRegular + } :: + StoredUser + scimUser = + scimUser0 + { email = Just inviteeEmail, + activated = False, + status = Just PendingInvitation, + teamId = Just scimTeam, + managedBy = Just ManagedByScim, + userType = Just UserTypeRegular + } :: + StoredUser + outcome = + runInviteScenarioObserved + InviteScenarioInput + { invitationTeam = manualTeam, + inviter, + otherUsers = [scimUser], + pendingScimUsers = [(scimTeam, inviteeEmail, scimUser.id)], + liveInvitations = [], + inviteeEmail, + observedUid = scimUser.id + } + in outcome + === Right + InviteScenarioObservation + { invitationResult = Nothing, + deletedUserIds = [], + observedUser = Just scimUser, + observedPendingScimUsers = [] + } + + prop "honors domain config from `brig.domain_registration`" $ \(tid :: TeamId) (preDomRegUpd :: DomainRegistrationUpdate) (preInviter :: StoredUser) diff --git a/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs index 404c0931af3..355b21891eb 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserGroupSubsystem/InterpreterSpec.hs @@ -80,7 +80,7 @@ type AllDependencies = `Append` '[ Input (Local ()), MockNow, NotificationSubsystem, - BackgroundJobsPublisher.BackgroundJobsPublisher, + BackgroundJobsPublisher.BackgroundJobPublisher, State [Push], Random.Random, Error UserGroupSubsystemError, @@ -114,7 +114,7 @@ interpretDependencies :: interpretDependencies initialUsers initialTeams = Random.randomToNull . evalState mempty - . noopBackgroundJobsPublisher + . noopBackgroundJobPublisher . inMemoryNotificationSubsystemInterpreter . evalState defaultTime . runInputConst (toLocalUnsafe (Domain "example.com") ()) @@ -133,7 +133,7 @@ runDependenciesWithReturnState initialUsers initialTeams = . runLocalErrors . Random.randomToNull . runState mempty - . noopBackgroundJobsPublisher + . noopBackgroundJobPublisher . inMemoryNotificationSubsystemInterpreter . evalState defaultTime . runInputConst (toLocalUnsafe (Domain "example.com") ()) diff --git a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs index 9f3eb8da2c9..68942a77cb3 100644 --- a/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs +++ b/libs/wire-subsystems/test/unit/Wire/UserSubsystem/InterpreterSpec.hs @@ -59,6 +59,7 @@ import Wire.API.User hiding (DeleteUser) import Wire.API.User.IdentityProvider (IdPList (..), team) import Wire.API.User.Search import Wire.API.UserEvent +import Wire.AppStore qualified as AppStore import Wire.AppSubsystem import Wire.AuthenticationSubsystem.Error import Wire.ClientSubsystem.Error (ClientError) @@ -1142,3 +1143,98 @@ spec = describe "UserSubsystem.Interpreter" do contactType = fromMaybe UserTypeRegular searchee.userType } pure $ result.searchResults === [expectedContact | fromMaybe True searchee.searchable] + + describe "getLocalAppProfiles" $ do + prop "does *not* include apps that are collaborators from other teams" $ + \(NotPendingStoredUser appUser_) + (NotPendingStoredUser ownerA_) + (NotPendingStoredUser ownerB_) + (teamAId :: TeamId) + (teamBId :: TeamId) + config -> + teamAId /= teamBId ==> + let localDomain = Domain "localdomain" + ownerA = ownerA_ {teamId = Just teamAId} :: StoredUser + ownerB = ownerB_ {teamId = Just teamBId} :: StoredUser + teamAOwnerId = toLocalUnsafe localDomain ownerA.id + teamBOwnerId = toLocalUnsafe localDomain ownerB.id + appUser = + appUser_ {userType = Just UserTypeApp, teamId = Just teamBId} :: StoredUser + storedApp = + AppStore.StoredApp + { id = appUser.id, + teamId = teamBId, + meta = mempty, + category = Category "other", + description = unsafeRange "test app", + creator = appUser.id + } + collab = + TeamCollaborator + { gUser = appUser.id, + gTeam = teamAId, + gPermissions = mempty + } + localBackend = + def + { users = [appUser, ownerA, ownerB], + apps = [storedApp], + teamCollaborators = Map.fromList [(teamAId, [collab])] + } + teams = + Map.fromList + [ (teamAId, [mkTeamMember ownerA.id fullPermissions Nothing defUserLegalHoldStatus]), + ( teamBId, + [ mkTeamMember ownerB.id fullPermissions Nothing defUserLegalHoldStatus, + mkTeamMember appUser.id fullPermissions Nothing defUserLegalHoldStatus + ] + ) + ] + result :: ([UserId], [UserId]) = + runNoFederationStack localBackend teams config $ do + let getAppId tid caller = + qUnqualified . (.profileQualifiedId) + <$$> getLocalAppProfiles caller tid + in (,) + <$> getAppId teamAId teamAOwnerId + <*> getAppId teamBId teamBOwnerId + in result === ([], [appUser.id]) + + prop "denies access when the caller's own team is not the requested team" . withMaxSuccess 1 $ + \(NotPendingStoredUser caller_) + (NotPendingStoredUser appUser_) + (callerTeamId :: TeamId) + (targetTeamId :: TeamId) + config -> + callerTeamId /= targetTeamId ==> + let localDomain = Domain "localdomain" + caller = caller_ {teamId = Just callerTeamId} :: StoredUser + callerId = toLocalUnsafe localDomain caller.id + appUser = appUser_ {userType = Just UserTypeApp, teamId = Just targetTeamId} :: StoredUser + storedApp = + AppStore.StoredApp + { id = appUser.id, + teamId = targetTeamId, + meta = mempty, + category = Category "other", + description = unsafeRange "test app", + creator = appUser.id + } + localBackend = + def + { users = [caller, appUser], + apps = [storedApp] + } + teams = + Map.fromList + [ ( callerTeamId, + [mkTeamMember caller.id fullPermissions Nothing defUserLegalHoldStatus] + ), + ( targetTeamId, + [mkTeamMember appUser.id fullPermissions Nothing defUserLegalHoldStatus] + ) + ] + result :: Either UserSubsystemError [UserProfile] = + runNoFederationStackUserSubsystemErrorEither localBackend teams config $ + getLocalAppProfiles callerId targetTeamId + in result === Left UserSubsystemProfileNotFound diff --git a/libs/wire-subsystems/wire-subsystems.cabal b/libs/wire-subsystems/wire-subsystems.cabal index 680a8e71519..f7627036f1c 100644 --- a/libs/wire-subsystems/wire-subsystems.cabal +++ b/libs/wire-subsystems/wire-subsystems.cabal @@ -91,6 +91,9 @@ common common-all , amazonka-ses , amazonka-sqs , amqp + , arbiter-core + , arbiter-hasql + , arbiter-migrations , async , attoparsec , base @@ -109,6 +112,7 @@ common common-all , contravariant , cookie , cql + , cron , crypton , crypton-asn1-encoding , crypton-asn1-types @@ -129,7 +133,7 @@ common common-all , HaskellNet-SSL , hasql , hasql-migration - , hasql-pool + , hasql-resource-pool , hasql-th , hasql-transaction , hex @@ -144,6 +148,7 @@ common common-all , imports , iproute , iso639 + , jwt-tools , kan-extensions , lens , lens-aeson @@ -155,10 +160,12 @@ common common-all , network-conduit-tls , network-uri , polysemy + , polysemy-check , polysemy-conc , polysemy-plugin , polysemy-time , polysemy-wire-zoo + , postgresql-connection-string , profunctors , prometheus-client , proto-lens @@ -178,6 +185,7 @@ common common-all , sop-core , ssl-util , statistics + , stm , stomp-queue , tagged , template @@ -233,8 +241,12 @@ library Wire.BackgroundJobsRunner.Interpreter Wire.BlockListStore Wire.BlockListStore.Cassandra + Wire.BoundedQueue + Wire.BoundedQueue.STM Wire.BrigAPIAccess Wire.BrigAPIAccess.Rpc + Wire.BudgetStore + Wire.BudgetStore.Cassandra Wire.ClientStore Wire.ClientStore.Cassandra Wire.ClientStore.DynamoDB @@ -303,6 +315,9 @@ library Wire.ConversationSubsystem.Util Wire.CustomBackendStore Wire.CustomBackendStore.Cassandra + Wire.DefaultSsoStore + Wire.DefaultSsoStore.Cassandra + Wire.DefaultSsoStore.Mem Wire.DeleteQueue Wire.DeleteQueue.InMemory Wire.DomainRegistrationStore @@ -315,6 +330,7 @@ library Wire.DomainVerificationChallengeStore.DualWrite Wire.DomainVerificationChallengeStore.Postgres Wire.EmailSending + Wire.EmailSending.Options Wire.EmailSending.SES Wire.EmailSending.SMTP Wire.EmailSubsystem @@ -353,6 +369,9 @@ library Wire.IdPConfigStore.Cassandra Wire.IdPConfigStore.Mem Wire.IdPConfigStore.Orphans + Wire.IdPRawMetadataStore + Wire.IdPRawMetadataStore.Cassandra + Wire.IdPRawMetadataStore.Mem Wire.IdPSubsystem Wire.IdPSubsystem.Interpreter Wire.IndexedUserStore @@ -363,6 +382,11 @@ library Wire.InternalEvent Wire.InvitationStore Wire.InvitationStore.Cassandra + Wire.JobSubsystem + Wire.JobSubsystem.ArbiterAdapter + Wire.JobSubsystem.Interpreter + Wire.JobSubsystem.Migrations + Wire.JwtTools Wire.LegalHold Wire.LegalHoldStore Wire.LegalHoldStore.Cassandra @@ -370,12 +394,20 @@ library Wire.LegalHoldStore.Env Wire.ListItems Wire.ListItems.Team.Cassandra + Wire.MeetingNotifier + Wire.MeetingNotifier.Interpreter + Wire.MeetingNotifier.NoOpInterpreter Wire.MeetingsStore Wire.MeetingsStore.Postgres Wire.MeetingsSubsystem Wire.MeetingsSubsystem.Interpreter + Wire.MeetingsSubsystem.Notification Wire.Migration Wire.MigrationLock + Wire.MlsKeyPackageStore + Wire.MlsKeyPackageStore.Cassandra + Wire.MlsKeyPackageSubsystem + Wire.MlsKeyPackageSubsystem.Interpreter Wire.NotificationSubsystem Wire.NotificationSubsystem.Interpreter Wire.Options.Galley @@ -397,17 +429,28 @@ library Wire.ProposalStore.Cassandra Wire.RateLimit Wire.RateLimit.Interpreter + Wire.Reporter + Wire.Reporter.Wai Wire.Rpc Wire.RpcException Wire.SAMLEmailSubsystem Wire.SAMLEmailSubsystem.Interpreter + Wire.SamlProtocolSettings + Wire.SamlProtocolSettings.Servant + Wire.ScimExternalIdStore + Wire.ScimExternalIdStore.Cassandra + Wire.ScimExternalIdStore.Mem Wire.ScimSubsystem Wire.ScimSubsystem.Error Wire.ScimSubsystem.Interpreter + Wire.ScimUserMetaStore + Wire.ScimUserMetaStore.Cassandra + Wire.ScimUserMetaStore.Mem Wire.ServiceStore Wire.ServiceStore.Cassandra Wire.SessionStore Wire.SessionStore.Cassandra + Wire.SFT Wire.SparAPIAccess Wire.SparAPIAccess.Rpc Wire.StoredConversation @@ -449,6 +492,8 @@ library Wire.UserKeyStore Wire.UserKeyStore.Cassandra Wire.UserList + Wire.UserPendingActivationStore + Wire.UserPendingActivationStore.Cassandra Wire.UserSearch.Metrics Wire.UserSearch.Migration Wire.UserSearch.Types @@ -463,6 +508,9 @@ library Wire.UserSubsystem.Interpreter Wire.UserSubsystem.UserSubsystemConfig Wire.Util + Wire.VerdictFormatStore + Wire.VerdictFormatStore.Cassandra + Wire.VerdictFormatStore.Mem Wire.VerificationCode Wire.VerificationCodeGen Wire.VerificationCodeStore @@ -525,6 +573,7 @@ library , network , network-conduit-tls , polysemy + , polysemy-check , polysemy-plugin , polysemy-time , polysemy-wire-zoo @@ -586,10 +635,13 @@ test-suite wire-subsystems-tests Wire.ConversationSubsystem.InterpreterSpec Wire.ConversationSubsystem.MessageSpec Wire.ConversationSubsystem.One2OneSpec + Wire.EmailSubsystem.TemplateFixtures + Wire.EmailSubsystem.TemplateSpec Wire.EnterpriseLoginSubsystem.InterpreterSpec Wire.FederationSubsystem.InternalsSpec Wire.HashPassword.InterpreterSpec Wire.IdPSubsystem.InterpreterSpec + Wire.MeetingNotifierSpec Wire.MeetingsSubsystem.InterpreterSpec Wire.MiniBackend Wire.MockInterpreters @@ -599,6 +651,7 @@ test-suite wire-subsystems-tests Wire.MockInterpreters.BackgroundJobPublisher Wire.MockInterpreters.BlockListStore Wire.MockInterpreters.ClientStore + Wire.MockInterpreters.ConversationStore Wire.MockInterpreters.ConversationSubsystem Wire.MockInterpreters.CryptoSign Wire.MockInterpreters.DomainRegistrationStore @@ -634,6 +687,7 @@ test-suite wire-subsystems-tests Wire.PropertySubsystem.InterpreterSpec Wire.RateLimited.InterpreterSpec Wire.SAMLEmailSubsystem.InterpreterSpec + Wire.ScimExternalIdStore.MemSpec Wire.ScimSubsystem.InterpreterSpec Wire.StoredConversationSpec Wire.TeamCollaboratorsSubsystem.InterpreterSpec diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 40cd37a0110..13ebf988279 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -58,6 +58,19 @@ let # END maintained by us # -------------------- + arbiter = { + src = inputs.arbiter; + packages = { + arbiter-core = "arbiter-core"; + arbiter-hasql = "arbiter-hasql"; + arbiter-migrations = "arbiter-migrations"; + arbiter-simple = "arbiter-simple"; + arbiter-test-common = "arbiter-test-common"; + arbiter-worker = "arbiter-worker"; + arbiter-worker-testkit = "arbiter-worker-testkit"; + }; + }; + bloodhound = { src = inputs.bloodhound; }; @@ -168,6 +181,12 @@ let src = inputs.postgresql-connection-string; }; + # Wire fork with pool acquisition timeout support. + # Update this to the upstream repo/rev once the PR is merged there. + hasql-resource-pool = { + src = inputs.hasql-resource-pool; + }; + cryptostore = { src = inputs.cryptostore; }; @@ -198,6 +217,7 @@ let version = "0.13"; sha256 = "sha256-m8Q1jwCyDrlEPbv2cZ/FIv/ey3dPjDVkmppzvi3Zjw4="; }; + }; # Name -> Source -> Maybe Subpath -> Drv mkGitDrv = name: src: subpath: diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index b176136ead1..7ace4edb661 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -26,10 +26,17 @@ hself: hsuper: { # Tests require a running postgresql hasql = hlib.dontCheck hsuper.hasql_1_10_3; - hasql-pool = hlib.dontCheck hsuper.hasql-pool_1_4_2; + # The library builds with hasql-1.10.x, but its packaged test suite still + # uses older hasql APIs. + hasql-resource-pool = hlib.dontCheck hsuper.hasql-resource-pool; hasql-migration = hlib.markUnbroken (hlib.doJailbreak (hlib.dontCheck hsuper.hasql-migration)); hasql-transaction = hlib.dontCheck hsuper.hasql-transaction_1_2_2; postgresql-binary = hlib.dontCheck (hsuper.postgresql-binary_0_15_0_1); + monad-logger-aeson = hlib.markUnbroken (hlib.dontCheck hsuper.monad-logger-aeson); + # Integration tests require a PostgreSQL server on localhost:5432. + arbiter-hasql = hlib.dontCheck hsuper.arbiter-hasql; + arbiter-simple = hlib.dontCheck hsuper.arbiter-simple; + arbiter-worker = hlib.dontCheck hsuper.arbiter-worker; # Test fixtures don't seem to be bundled for Hackage hsaml2 = hlib.dontCheck (hsuper.hsaml2); diff --git a/nix/wire-server.nix b/nix/wire-server.nix index 47ff465720e..c3a3010efe6 100644 --- a/nix/wire-server.nix +++ b/nix/wire-server.nix @@ -159,6 +159,22 @@ let inherit hlib mls-test-cli; }); + # 'Test.Wire.API.Routes.OAuthScopes' checks the OAuth scopes nginz enforces + # against the ones the swagger docs advertise, so it needs + # charts/nginz/values.yaml at compile time. Only a package's own directory is + # copied into the build sandbox, so splice the chart in -- and by making it + # part of 'src', changing the chart also invalidates the build. + wireApiWithNginzChart = hself: hsuper: { + wire-api = hlib.overrideCabal hsuper.wire-api (old: { + src = pkgs.runCommand "wire-api-src" { } '' + cp -r ${old.src} $out + chmod -R u+w $out + mkdir -p $out/test/unit/generated + cp ${../charts/nginz/values.yaml} $out/test/unit/generated/nginz-values.yaml + ''; + }); + }; + executables = hself: hsuper: attrsets.genAttrs (builtins.attrNames executablesMap) (e: withCleanedPath hsuper.${e}); @@ -173,6 +189,7 @@ let overrides = lib.composeManyExtensions [ pinnedPackages (localPackages localMods) + wireApiWithNginzChart manualOverrides executables staticExecutables @@ -390,7 +407,24 @@ let }; wireServerPackages = (builtins.attrNames (localPackages localModsEnableAll { } { })); - hoogle = (hPkgs localModsOnlyDocs).hoogleWithPackages (p: builtins.map (e: p.${e}) wireServerPackages); + # Haddock for the hoogle image: the image serves the hoogle database and + # haddock HTML (nixpkgs hoogle.nix). The quickjump index is not needed + # for hoogle search: galley's haddock has hung indefinitely in CI right + # before "Documentation created" (task timed out after 1h10m). Haddock + # (and the -O0 compile) are also forced serial: parallel haddock + # (-j$NIX_BUILD_CORES) is the prime deadlock suspect. This only affects + # the hoogle image, not the production docker images (built from + # imagesNoDocs, enableDocs = false). + hoogleHaddockOverrides = hself: hsuper: + builtins.mapAttrs + (_: drv: + hlib.overrideCabal + (hlib.disableParallelBuilding drv) + (old: { doHaddockQuickjump = false; })) + (lib.genAttrs wireServerPackages (name: hsuper.${name})); + + hoogle = ((hPkgs localModsOnlyDocs).extend hoogleHaddockOverrides).hoogleWithPackages + (p: builtins.map (e: p.${e}) wireServerPackages); # More about dockerTools.streamLayeredImage: # https://nixos.org/manual/nixpkgs/unstable/#ssec-pkgs-dockerTools-streamLayeredImage @@ -436,6 +470,7 @@ let pkgs.cfssl pkgs.awscli2 (hlib.justStaticExecutables pkgs.haskellPackages.cabal-fmt) + (hlib.justStaticExecutables pkgs.haskellPackages.headroom) (hlib.justStaticExecutables pkgs.haskellPackages.weeder) ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.skopeo @@ -519,7 +554,6 @@ let pkgs.nix-prefetch-git pkgs.haskellPackages.cabal-plan pkgs.lsof - pkgs.haskellPackages.headroom profileEnv ] ++ ghcWithPackages diff --git a/postgres-schema.sql b/postgres-schema.sql index c60938bceb0..b2d1587ab49 100644 --- a/postgres-schema.sql +++ b/postgres-schema.sql @@ -9,8 +9,8 @@ \restrict 79bbfb4630959c48307653a5cd3d83f2582b3c2210f75f10d79e3ebf0015620 --- Dumped from database version 17.9 --- Dumped by pg_dump version 17.9 +-- Dumped from database version 17.10 +-- Dumped by pg_dump version 17.10 SET statement_timeout = 0; SET lock_timeout = 0; @@ -24,6 +24,15 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: arbiter; Type: SCHEMA; Schema: -; Owner: wire-server +-- + +CREATE SCHEMA arbiter; + + +ALTER SCHEMA arbiter OWNER TO "wire-server"; + -- -- Name: public; Type: SCHEMA; Schema: -; Owner: wire-server -- @@ -54,6 +63,608 @@ CREATE TYPE public.recurrence_frequency AS ENUM ( ALTER TYPE public.recurrence_frequency OWNER TO "wire-server"; +-- +-- Name: maintain_conversations_groups_delete(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.maintain_conversations_groups_delete() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1) THEN + RETURN NULL; + END IF; + + -- Lock group rows in group_key order to avoid deadlock with concurrent triggers. + PERFORM 1 FROM "arbiter"."conversations_groups" g + WHERE g.group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL) + ORDER BY g.group_key + FOR UPDATE; + + UPDATE "arbiter"."conversations_groups" g + SET job_count = g.job_count - sub.removed_count, + min_priority = COALESCE(sub.new_min_priority, g.min_priority), + min_id = COALESCE(sub.new_min_id, g.min_id), + ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count), + next_due = sub.new_next_due, + in_flight_until = CASE + WHEN sub.had_inflight THEN sub.surviving_ift + ELSE g.in_flight_until + END + FROM ( + SELECT d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight, + MIN(t.priority) AS new_min_priority, + MIN(t.id) AS new_min_id, + MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due, + MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift + FROM ( + SELECT group_key, COUNT(*) AS removed_count, + COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended) AS removed_ready_count, + bool_or(not_visible_until > NOW() AND NOT suspended AND (attempts > 0 OR throttled_until > NOW())) AS had_inflight + FROM old_table + WHERE group_key IS NOT NULL + GROUP BY group_key + ) d + LEFT JOIN "arbiter"."conversations" t ON t.group_key = d.group_key + GROUP BY d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight + ) sub + WHERE g.group_key = sub.group_key; + + DELETE FROM "arbiter"."conversations_groups" + WHERE job_count <= 0 + AND group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL); + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION arbiter.maintain_conversations_groups_delete() OWNER TO "wire-server"; + +-- +-- Name: maintain_conversations_groups_insert(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.maintain_conversations_groups_insert() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1) THEN + RETURN NULL; + END IF; + + -- Lock group rows in group_key order to avoid deadlock with concurrent triggers. + PERFORM 1 FROM "arbiter"."conversations_groups" g + WHERE g.group_key IN (SELECT group_key FROM new_table WHERE group_key IS NOT NULL) + ORDER BY g.group_key + FOR UPDATE; + + INSERT INTO "arbiter"."conversations_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due) + SELECT group_key, + MIN(priority), + MIN(id), + COUNT(*), + COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended), + MIN(not_visible_until) FILTER (WHERE not_visible_until IS NOT NULL AND NOT suspended) + FROM new_table + WHERE group_key IS NOT NULL + GROUP BY group_key + ORDER BY group_key + ON CONFLICT (group_key) DO UPDATE SET + min_priority = LEAST("arbiter"."conversations_groups".min_priority, EXCLUDED.min_priority), + min_id = LEAST("arbiter"."conversations_groups".min_id, EXCLUDED.min_id), + job_count = "arbiter"."conversations_groups".job_count + EXCLUDED.job_count, + ready_count = "arbiter"."conversations_groups".ready_count + EXCLUDED.ready_count, + next_due = LEAST("arbiter"."conversations_groups".next_due, EXCLUDED.next_due), + in_flight_until = CASE WHEN "arbiter"."conversations_groups".in_flight_until <= NOW() + THEN NULL ELSE "arbiter"."conversations_groups".in_flight_until END; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION arbiter.maintain_conversations_groups_insert() OWNER TO "wire-server"; + +-- +-- Name: maintain_conversations_groups_update(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.maintain_conversations_groups_update() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1 + ) AND NOT EXISTS ( + SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1 + ) THEN + RETURN NULL; + END IF; + + -- Lock group rows (old and new) in group_key order to avoid deadlock with concurrent triggers. + PERFORM 1 FROM "arbiter"."conversations_groups" g + WHERE g.group_key IN ( + SELECT group_key FROM new_table WHERE group_key IS NOT NULL + UNION + SELECT group_key FROM old_table WHERE group_key IS NOT NULL + ) + ORDER BY g.group_key + FOR UPDATE; + + -- Step 1: Full rescan - recompute in_flight_until when not_visible_until decreases or suspended changes + UPDATE "arbiter"."conversations_groups" g + SET in_flight_until = sub.new_ift + FROM ( + SELECT t.group_key, + MAX(t.not_visible_until) FILTER ( + WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW()) + ) AS new_ift + FROM "arbiter"."conversations" t + WHERE t.group_key IN ( + SELECT n.group_key FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND (o.not_visible_until IS DISTINCT FROM n.not_visible_until + OR o.suspended IS DISTINCT FROM n.suspended + OR o.attempts IS DISTINCT FROM n.attempts) + AND ( + n.not_visible_until > NOW() AND NOT n.suspended AND n.attempts > 0 + AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW() + OR n.not_visible_until > o.not_visible_until) + ) IS NOT TRUE + ) + GROUP BY t.group_key + ) sub + WHERE g.group_key = sub.group_key + AND g.in_flight_until IS DISTINCT FROM sub.new_ift; + + -- Step 2: group_key change (dedup replace) - remove from old group + UPDATE "arbiter"."conversations_groups" g + SET job_count = g.job_count - sub.cnt, + min_priority = COALESCE(sub.new_min_priority, g.min_priority), + min_id = COALESCE(sub.new_min_id, g.min_id), + ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count), + next_due = sub.new_next_due, + in_flight_until = CASE + WHEN sub.had_inflight THEN sub.surviving_ift + ELSE g.in_flight_until + END + FROM ( + SELECT d.group_key, d.cnt, d.removed_ready_count, d.had_inflight, + MIN(t.priority) AS new_min_priority, MIN(t.id) AS new_min_id, + MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due, + MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift + FROM ( + SELECT o.group_key, COUNT(*) AS cnt, + COUNT(*) FILTER (WHERE o.not_visible_until IS NULL AND NOT o.suspended) AS removed_ready_count, + bool_or(o.not_visible_until > NOW() AND NOT o.suspended AND (o.attempts > 0 OR o.throttled_until > NOW())) AS had_inflight + FROM old_table o + JOIN new_table n ON o.id = n.id + WHERE o.group_key IS NOT NULL + AND o.group_key IS DISTINCT FROM n.group_key + GROUP BY o.group_key + ) d + LEFT JOIN "arbiter"."conversations" t ON t.group_key = d.group_key + GROUP BY d.group_key, d.cnt, d.removed_ready_count, d.had_inflight + ) sub + WHERE g.group_key = sub.group_key; + + DELETE FROM "arbiter"."conversations_groups" + WHERE job_count <= 0 + AND group_key IN ( + SELECT o.group_key FROM old_table o + JOIN new_table n ON o.id = n.id + WHERE o.group_key IS NOT NULL + AND o.group_key IS DISTINCT FROM n.group_key + ); + + -- Step 3: group_key change - add to new group + INSERT INTO "arbiter"."conversations_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due) + SELECT n.group_key, MIN(n.priority), MIN(n.id), COUNT(*), + COUNT(*) FILTER (WHERE n.not_visible_until IS NULL AND NOT n.suspended), + MIN(n.not_visible_until) FILTER (WHERE n.not_visible_until IS NOT NULL AND NOT n.suspended) + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND o.group_key IS DISTINCT FROM n.group_key + GROUP BY n.group_key + ORDER BY n.group_key + ON CONFLICT (group_key) DO UPDATE SET + min_priority = LEAST("arbiter"."conversations_groups".min_priority, EXCLUDED.min_priority), + min_id = LEAST("arbiter"."conversations_groups".min_id, EXCLUDED.min_id), + job_count = "arbiter"."conversations_groups".job_count + EXCLUDED.job_count, + ready_count = "arbiter"."conversations_groups".ready_count + EXCLUDED.ready_count, + next_due = LEAST("arbiter"."conversations_groups".next_due, EXCLUDED.next_due); + + -- Step 4: same-group ordering/visibility change - recompute min and next_due. + UPDATE "arbiter"."conversations_groups" g + SET min_priority = sub.new_min_priority, + min_id = sub.new_min_id, + next_due = sub.new_next_due + FROM ( + SELECT d.group_key, + MIN(t.priority) AS new_min_priority, + MIN(t.id) AS new_min_id, + MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due + FROM ( + SELECT DISTINCT n.group_key + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND n.group_key IS NOT DISTINCT FROM o.group_key + AND (n.priority IS DISTINCT FROM o.priority + OR o.not_visible_until IS DISTINCT FROM n.not_visible_until + OR o.suspended IS DISTINCT FROM n.suspended) + ) d + LEFT JOIN "arbiter"."conversations" t ON t.group_key = d.group_key + GROUP BY d.group_key + ) sub + WHERE g.group_key = sub.group_key + AND (g.min_priority IS DISTINCT FROM sub.new_min_priority + OR g.min_id IS DISTINCT FROM sub.new_min_id + OR g.next_due IS DISTINCT FROM sub.new_next_due); + + -- Step 5: commutative in_flight_until extend and ready_count delta in one write. + UPDATE "arbiter"."conversations_groups" g + SET in_flight_until = GREATEST(g.in_flight_until, s.new_ift), + ready_count = GREATEST(0, g.ready_count + COALESCE(s.delta, 0)) + FROM ( + SELECT COALESCE(ift.group_key, rc.group_key) AS group_key, ift.new_ift, rc.delta + FROM ( + SELECT n.group_key, MAX(n.not_visible_until) AS new_ift + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND n.not_visible_until > NOW() + AND NOT n.suspended + AND n.attempts > 0 + AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW() + OR n.not_visible_until > o.not_visible_until) + GROUP BY n.group_key + ) ift + FULL OUTER JOIN ( + SELECT group_key, delta FROM ( + SELECT n.group_key, + SUM( + (CASE WHEN n.not_visible_until IS NULL AND NOT n.suspended THEN 1 ELSE 0 END) + - (CASE WHEN o.not_visible_until IS NULL AND NOT o.suspended THEN 1 ELSE 0 END) + )::int AS delta + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND n.group_key IS NOT DISTINCT FROM o.group_key + GROUP BY n.group_key + ) z + WHERE delta <> 0 + ) rc ON ift.group_key = rc.group_key + ) s + WHERE g.group_key = s.group_key; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION arbiter.maintain_conversations_groups_update() OWNER TO "wire-server"; + +-- +-- Name: maintain_meetings_groups_delete(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.maintain_meetings_groups_delete() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1) THEN + RETURN NULL; + END IF; + + -- Lock group rows in group_key order to avoid deadlock with concurrent triggers. + PERFORM 1 FROM "arbiter"."meetings_groups" g + WHERE g.group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL) + ORDER BY g.group_key + FOR UPDATE; + + UPDATE "arbiter"."meetings_groups" g + SET job_count = g.job_count - sub.removed_count, + min_priority = COALESCE(sub.new_min_priority, g.min_priority), + min_id = COALESCE(sub.new_min_id, g.min_id), + ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count), + next_due = sub.new_next_due, + in_flight_until = CASE + WHEN sub.had_inflight THEN sub.surviving_ift + ELSE g.in_flight_until + END + FROM ( + SELECT d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight, + MIN(t.priority) AS new_min_priority, + MIN(t.id) AS new_min_id, + MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due, + MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift + FROM ( + SELECT group_key, COUNT(*) AS removed_count, + COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended) AS removed_ready_count, + bool_or(not_visible_until > NOW() AND NOT suspended AND (attempts > 0 OR throttled_until > NOW())) AS had_inflight + FROM old_table + WHERE group_key IS NOT NULL + GROUP BY group_key + ) d + LEFT JOIN "arbiter"."meetings" t ON t.group_key = d.group_key + GROUP BY d.group_key, d.removed_count, d.removed_ready_count, d.had_inflight + ) sub + WHERE g.group_key = sub.group_key; + + DELETE FROM "arbiter"."meetings_groups" + WHERE job_count <= 0 + AND group_key IN (SELECT group_key FROM old_table WHERE group_key IS NOT NULL); + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION arbiter.maintain_meetings_groups_delete() OWNER TO "wire-server"; + +-- +-- Name: maintain_meetings_groups_insert(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.maintain_meetings_groups_insert() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1) THEN + RETURN NULL; + END IF; + + -- Lock group rows in group_key order to avoid deadlock with concurrent triggers. + PERFORM 1 FROM "arbiter"."meetings_groups" g + WHERE g.group_key IN (SELECT group_key FROM new_table WHERE group_key IS NOT NULL) + ORDER BY g.group_key + FOR UPDATE; + + INSERT INTO "arbiter"."meetings_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due) + SELECT group_key, + MIN(priority), + MIN(id), + COUNT(*), + COUNT(*) FILTER (WHERE not_visible_until IS NULL AND NOT suspended), + MIN(not_visible_until) FILTER (WHERE not_visible_until IS NOT NULL AND NOT suspended) + FROM new_table + WHERE group_key IS NOT NULL + GROUP BY group_key + ORDER BY group_key + ON CONFLICT (group_key) DO UPDATE SET + min_priority = LEAST("arbiter"."meetings_groups".min_priority, EXCLUDED.min_priority), + min_id = LEAST("arbiter"."meetings_groups".min_id, EXCLUDED.min_id), + job_count = "arbiter"."meetings_groups".job_count + EXCLUDED.job_count, + ready_count = "arbiter"."meetings_groups".ready_count + EXCLUDED.ready_count, + next_due = LEAST("arbiter"."meetings_groups".next_due, EXCLUDED.next_due), + in_flight_until = CASE WHEN "arbiter"."meetings_groups".in_flight_until <= NOW() + THEN NULL ELSE "arbiter"."meetings_groups".in_flight_until END; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION arbiter.maintain_meetings_groups_insert() OWNER TO "wire-server"; + +-- +-- Name: maintain_meetings_groups_update(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.maintain_meetings_groups_update() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM new_table WHERE group_key IS NOT NULL LIMIT 1 + ) AND NOT EXISTS ( + SELECT 1 FROM old_table WHERE group_key IS NOT NULL LIMIT 1 + ) THEN + RETURN NULL; + END IF; + + -- Lock group rows (old and new) in group_key order to avoid deadlock with concurrent triggers. + PERFORM 1 FROM "arbiter"."meetings_groups" g + WHERE g.group_key IN ( + SELECT group_key FROM new_table WHERE group_key IS NOT NULL + UNION + SELECT group_key FROM old_table WHERE group_key IS NOT NULL + ) + ORDER BY g.group_key + FOR UPDATE; + + -- Step 1: Full rescan - recompute in_flight_until when not_visible_until decreases or suspended changes + UPDATE "arbiter"."meetings_groups" g + SET in_flight_until = sub.new_ift + FROM ( + SELECT t.group_key, + MAX(t.not_visible_until) FILTER ( + WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW()) + ) AS new_ift + FROM "arbiter"."meetings" t + WHERE t.group_key IN ( + SELECT n.group_key FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND (o.not_visible_until IS DISTINCT FROM n.not_visible_until + OR o.suspended IS DISTINCT FROM n.suspended + OR o.attempts IS DISTINCT FROM n.attempts) + AND ( + n.not_visible_until > NOW() AND NOT n.suspended AND n.attempts > 0 + AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW() + OR n.not_visible_until > o.not_visible_until) + ) IS NOT TRUE + ) + GROUP BY t.group_key + ) sub + WHERE g.group_key = sub.group_key + AND g.in_flight_until IS DISTINCT FROM sub.new_ift; + + -- Step 2: group_key change (dedup replace) - remove from old group + UPDATE "arbiter"."meetings_groups" g + SET job_count = g.job_count - sub.cnt, + min_priority = COALESCE(sub.new_min_priority, g.min_priority), + min_id = COALESCE(sub.new_min_id, g.min_id), + ready_count = GREATEST(0, g.ready_count - sub.removed_ready_count), + next_due = sub.new_next_due, + in_flight_until = CASE + WHEN sub.had_inflight THEN sub.surviving_ift + ELSE g.in_flight_until + END + FROM ( + SELECT d.group_key, d.cnt, d.removed_ready_count, d.had_inflight, + MIN(t.priority) AS new_min_priority, MIN(t.id) AS new_min_id, + MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due, + MAX(t.not_visible_until) FILTER (WHERE t.not_visible_until > NOW() AND NOT t.suspended AND (t.attempts > 0 OR t.throttled_until > NOW())) AS surviving_ift + FROM ( + SELECT o.group_key, COUNT(*) AS cnt, + COUNT(*) FILTER (WHERE o.not_visible_until IS NULL AND NOT o.suspended) AS removed_ready_count, + bool_or(o.not_visible_until > NOW() AND NOT o.suspended AND (o.attempts > 0 OR o.throttled_until > NOW())) AS had_inflight + FROM old_table o + JOIN new_table n ON o.id = n.id + WHERE o.group_key IS NOT NULL + AND o.group_key IS DISTINCT FROM n.group_key + GROUP BY o.group_key + ) d + LEFT JOIN "arbiter"."meetings" t ON t.group_key = d.group_key + GROUP BY d.group_key, d.cnt, d.removed_ready_count, d.had_inflight + ) sub + WHERE g.group_key = sub.group_key; + + DELETE FROM "arbiter"."meetings_groups" + WHERE job_count <= 0 + AND group_key IN ( + SELECT o.group_key FROM old_table o + JOIN new_table n ON o.id = n.id + WHERE o.group_key IS NOT NULL + AND o.group_key IS DISTINCT FROM n.group_key + ); + + -- Step 3: group_key change - add to new group + INSERT INTO "arbiter"."meetings_groups" (group_key, min_priority, min_id, job_count, ready_count, next_due) + SELECT n.group_key, MIN(n.priority), MIN(n.id), COUNT(*), + COUNT(*) FILTER (WHERE n.not_visible_until IS NULL AND NOT n.suspended), + MIN(n.not_visible_until) FILTER (WHERE n.not_visible_until IS NOT NULL AND NOT n.suspended) + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND o.group_key IS DISTINCT FROM n.group_key + GROUP BY n.group_key + ORDER BY n.group_key + ON CONFLICT (group_key) DO UPDATE SET + min_priority = LEAST("arbiter"."meetings_groups".min_priority, EXCLUDED.min_priority), + min_id = LEAST("arbiter"."meetings_groups".min_id, EXCLUDED.min_id), + job_count = "arbiter"."meetings_groups".job_count + EXCLUDED.job_count, + ready_count = "arbiter"."meetings_groups".ready_count + EXCLUDED.ready_count, + next_due = LEAST("arbiter"."meetings_groups".next_due, EXCLUDED.next_due); + + -- Step 4: same-group ordering/visibility change - recompute min and next_due. + UPDATE "arbiter"."meetings_groups" g + SET min_priority = sub.new_min_priority, + min_id = sub.new_min_id, + next_due = sub.new_next_due + FROM ( + SELECT d.group_key, + MIN(t.priority) AS new_min_priority, + MIN(t.id) AS new_min_id, + MIN(t.not_visible_until) FILTER (WHERE t.not_visible_until IS NOT NULL AND NOT t.suspended) AS new_next_due + FROM ( + SELECT DISTINCT n.group_key + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND n.group_key IS NOT DISTINCT FROM o.group_key + AND (n.priority IS DISTINCT FROM o.priority + OR o.not_visible_until IS DISTINCT FROM n.not_visible_until + OR o.suspended IS DISTINCT FROM n.suspended) + ) d + LEFT JOIN "arbiter"."meetings" t ON t.group_key = d.group_key + GROUP BY d.group_key + ) sub + WHERE g.group_key = sub.group_key + AND (g.min_priority IS DISTINCT FROM sub.new_min_priority + OR g.min_id IS DISTINCT FROM sub.new_min_id + OR g.next_due IS DISTINCT FROM sub.new_next_due); + + -- Step 5: commutative in_flight_until extend and ready_count delta in one write. + UPDATE "arbiter"."meetings_groups" g + SET in_flight_until = GREATEST(g.in_flight_until, s.new_ift), + ready_count = GREATEST(0, g.ready_count + COALESCE(s.delta, 0)) + FROM ( + SELECT COALESCE(ift.group_key, rc.group_key) AS group_key, ift.new_ift, rc.delta + FROM ( + SELECT n.group_key, MAX(n.not_visible_until) AS new_ift + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND n.not_visible_until > NOW() + AND NOT n.suspended + AND n.attempts > 0 + AND (o.not_visible_until IS NULL OR o.not_visible_until <= NOW() + OR n.not_visible_until > o.not_visible_until) + GROUP BY n.group_key + ) ift + FULL OUTER JOIN ( + SELECT group_key, delta FROM ( + SELECT n.group_key, + SUM( + (CASE WHEN n.not_visible_until IS NULL AND NOT n.suspended THEN 1 ELSE 0 END) + - (CASE WHEN o.not_visible_until IS NULL AND NOT o.suspended THEN 1 ELSE 0 END) + )::int AS delta + FROM new_table n + JOIN old_table o ON o.id = n.id + WHERE n.group_key IS NOT NULL + AND n.group_key IS NOT DISTINCT FROM o.group_key + GROUP BY n.group_key + ) z + WHERE delta <> 0 + ) rc ON ift.group_key = rc.group_key + ) s + WHERE g.group_key = s.group_key; + + RETURN NULL; +END; +$$; + + +ALTER FUNCTION arbiter.maintain_meetings_groups_update() OWNER TO "wire-server"; + +-- +-- Name: notify_conversations_created(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.notify_conversations_created() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + PERFORM pg_notify('conversations_created', ''); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION arbiter.notify_conversations_created() OWNER TO "wire-server"; + +-- +-- Name: notify_meetings_created(); Type: FUNCTION; Schema: arbiter; Owner: wire-server +-- + +CREATE FUNCTION arbiter.notify_meetings_created() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + PERFORM pg_notify('meetings_created', ''); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION arbiter.notify_meetings_created() OWNER TO "wire-server"; + -- -- Name: update_updated_at(); Type: FUNCTION; Schema: public; Owner: wire-server -- @@ -74,6 +685,563 @@ SET default_tablespace = ''; SET default_table_access_method = heap; +-- +-- Name: arbiter_concurrency; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE UNLOGGED TABLE arbiter.arbiter_concurrency ( + concurrency_key text NOT NULL, + concurrency_prefix text NOT NULL, + in_flight integer DEFAULT 0 NOT NULL +) +WITH (fillfactor='80'); + + +ALTER TABLE arbiter.arbiter_concurrency OWNER TO "wire-server"; + +-- +-- Name: arbiter_concurrency_policies; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.arbiter_concurrency_policies ( + prefix_id text NOT NULL, + default_limit integer NOT NULL, + override_limit integer, + CONSTRAINT arbiter_concurrency_policies_default_limit_check CHECK ((default_limit > 0)), + CONSTRAINT arbiter_concurrency_policies_override_limit_check CHECK ((override_limit >= 0)) +); + + +ALTER TABLE arbiter.arbiter_concurrency_policies OWNER TO "wire-server"; + +-- +-- Name: arbiter_gates; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.arbiter_gates ( + task_name text NOT NULL, + last_run_at timestamp with time zone DEFAULT '1970-01-01 00:00:00+00'::timestamp with time zone NOT NULL +); + + +ALTER TABLE arbiter.arbiter_gates OWNER TO "wire-server"; + +-- +-- Name: arbiter_queues; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.arbiter_queues ( + queue_name text NOT NULL, + paused boolean DEFAULT false NOT NULL, + paused_at timestamp with time zone, + metadata jsonb, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE arbiter.arbiter_queues OWNER TO "wire-server"; + +-- +-- Name: arbiter_rate_limit_policies; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.arbiter_rate_limit_policies ( + prefix_id text NOT NULL, + default_max_tokens double precision NOT NULL, + default_refill_amount double precision NOT NULL, + default_interval double precision NOT NULL, + override_max_tokens double precision, + override_refill_amount double precision, + override_interval double precision, + CONSTRAINT arbiter_rate_limit_policies_default_interval_check CHECK ((default_interval > (0)::double precision)), + CONSTRAINT arbiter_rate_limit_policies_default_max_tokens_check CHECK ((default_max_tokens >= (0)::double precision)), + CONSTRAINT arbiter_rate_limit_policies_default_refill_amount_check CHECK ((default_refill_amount >= (0)::double precision)), + CONSTRAINT arbiter_rate_limit_policies_override_interval_check CHECK ((override_interval > (0)::double precision)), + CONSTRAINT arbiter_rate_limit_policies_override_max_tokens_check CHECK ((override_max_tokens >= (0)::double precision)), + CONSTRAINT arbiter_rate_limit_policies_override_refill_amount_check CHECK ((override_refill_amount >= (0)::double precision)) +); + + +ALTER TABLE arbiter.arbiter_rate_limit_policies OWNER TO "wire-server"; + +-- +-- Name: arbiter_rate_limits; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE UNLOGGED TABLE arbiter.arbiter_rate_limits ( + rate_limit_key text NOT NULL, + policy_prefix text NOT NULL, + tokens double precision NOT NULL, + last_refill timestamp with time zone NOT NULL +) +WITH (fillfactor='80'); + + +ALTER TABLE arbiter.arbiter_rate_limits OWNER TO "wire-server"; + +-- +-- Name: arbiter_workers; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.arbiter_workers ( + worker_id uuid NOT NULL, + queue_name text NOT NULL, + host_name text, + worker_count integer, + started_at timestamp with time zone DEFAULT now() NOT NULL, + last_heartbeat timestamp with time zone DEFAULT now() NOT NULL, + shutting_down boolean DEFAULT false NOT NULL, + paused boolean DEFAULT false NOT NULL, + stale_threshold_secs double precision DEFAULT 300 NOT NULL, + metadata jsonb +); + + +ALTER TABLE arbiter.arbiter_workers OWNER TO "wire-server"; + +-- +-- Name: conversations; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.conversations ( + id bigint NOT NULL, + payload jsonb NOT NULL, + group_key text, + inserted_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone, + last_attempted_at timestamp with time zone, + not_visible_until timestamp with time zone, + attempts integer DEFAULT 0 NOT NULL, + last_error text, + priority integer DEFAULT 0 NOT NULL, + dedup_key text, + dedup_strategy text, + max_attempts integer DEFAULT 10, + parent_id bigint, + parent_state jsonb, + suspended boolean DEFAULT false NOT NULL, + claimed_by uuid, + rate_limit_key text, + rate_limit_prefix text, + throttled_until timestamp with time zone, + concurrency_key text, + concurrency_prefix text, + rate_limit_cost double precision DEFAULT 1 NOT NULL, + cancel_requested_at timestamp with time zone, + archive_for integer +) +WITH (fillfactor='100'); + + +ALTER TABLE arbiter.conversations OWNER TO "wire-server"; + +-- +-- Name: conversations_archive; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.conversations_archive ( + id bigint NOT NULL, + completed_at timestamp with time zone DEFAULT now() NOT NULL, + archive_expires_at timestamp with time zone NOT NULL, + job_id bigint NOT NULL, + claimed_by uuid, + archive_for integer, + rate_limit_key text, + rate_limit_prefix text, + rate_limit_cost double precision, + concurrency_key text, + concurrency_prefix text, + result jsonb, + payload jsonb NOT NULL, + group_key text, + inserted_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone, + last_attempted_at timestamp with time zone, + not_visible_until timestamp with time zone, + attempts integer DEFAULT 0 NOT NULL, + last_error text, + priority integer DEFAULT 0 NOT NULL, + dedup_key text, + dedup_strategy text, + max_attempts integer, + parent_id bigint, + parent_state jsonb, + suspended boolean DEFAULT false NOT NULL +); + + +ALTER TABLE arbiter.conversations_archive OWNER TO "wire-server"; + +-- +-- Name: conversations_archive_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server +-- + +CREATE SEQUENCE arbiter.conversations_archive_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE arbiter.conversations_archive_id_seq OWNER TO "wire-server"; + +-- +-- Name: conversations_archive_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server +-- + +ALTER SEQUENCE arbiter.conversations_archive_id_seq OWNED BY arbiter.conversations_archive.id; + + +-- +-- Name: conversations_dlq; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.conversations_dlq ( + id bigint NOT NULL, + failed_at timestamp with time zone DEFAULT now() NOT NULL, + job_id bigint NOT NULL, + payload jsonb NOT NULL, + group_key text, + inserted_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone, + last_attempted_at timestamp with time zone, + not_visible_until timestamp with time zone, + attempts integer DEFAULT 0 NOT NULL, + last_error text, + priority integer DEFAULT 0 NOT NULL, + dedup_key text, + dedup_strategy text, + max_attempts integer, + parent_id bigint, + parent_state jsonb, + suspended boolean DEFAULT false NOT NULL, + claimed_by uuid, + rate_limit_key text, + rate_limit_prefix text, + concurrency_key text, + concurrency_prefix text, + rate_limit_cost double precision DEFAULT 1 NOT NULL, + archive_for integer +); + + +ALTER TABLE arbiter.conversations_dlq OWNER TO "wire-server"; + +-- +-- Name: conversations_dlq_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server +-- + +CREATE SEQUENCE arbiter.conversations_dlq_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE arbiter.conversations_dlq_id_seq OWNER TO "wire-server"; + +-- +-- Name: conversations_dlq_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server +-- + +ALTER SEQUENCE arbiter.conversations_dlq_id_seq OWNED BY arbiter.conversations_dlq.id; + + +-- +-- Name: conversations_groups; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.conversations_groups ( + group_key text NOT NULL, + min_priority integer DEFAULT 0 NOT NULL, + min_id bigint DEFAULT 0 NOT NULL, + job_count integer DEFAULT 0 NOT NULL, + in_flight_until timestamp with time zone, + ready_count integer DEFAULT 0 NOT NULL, + next_due timestamp with time zone +); + + +ALTER TABLE arbiter.conversations_groups OWNER TO "wire-server"; + +-- +-- Name: conversations_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server +-- + +CREATE SEQUENCE arbiter.conversations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE arbiter.conversations_id_seq OWNER TO "wire-server"; + +-- +-- Name: conversations_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server +-- + +ALTER SEQUENCE arbiter.conversations_id_seq OWNED BY arbiter.conversations.id; + + +-- +-- Name: conversations_results; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.conversations_results ( + parent_id bigint NOT NULL, + child_id bigint NOT NULL, + result jsonb NOT NULL +); + + +ALTER TABLE arbiter.conversations_results OWNER TO "wire-server"; + +-- +-- Name: cron_schedules; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.cron_schedules ( + name text NOT NULL, + default_expression text NOT NULL, + default_overlap text NOT NULL, + override_expression text, + override_overlap text, + enabled boolean DEFAULT true NOT NULL, + last_fired_at timestamp with time zone, + last_checked_at timestamp with time zone, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + default_timezone text, + override_timezone text, + queue_name text DEFAULT 'pre-migration'::text NOT NULL, + run_requested_at timestamp with time zone, + last_manual_run_at timestamp with time zone, + CONSTRAINT cron_schedules_default_overlap_check CHECK ((default_overlap = ANY (ARRAY['SkipOverlap'::text, 'AllowOverlap'::text]))), + CONSTRAINT cron_schedules_override_overlap_check CHECK (((override_overlap IS NULL) OR (override_overlap = ANY (ARRAY['SkipOverlap'::text, 'AllowOverlap'::text])))) +); + + +ALTER TABLE arbiter.cron_schedules OWNER TO "wire-server"; + +-- +-- Name: meetings; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.meetings ( + id bigint NOT NULL, + payload jsonb NOT NULL, + group_key text, + inserted_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone, + last_attempted_at timestamp with time zone, + not_visible_until timestamp with time zone, + attempts integer DEFAULT 0 NOT NULL, + last_error text, + priority integer DEFAULT 0 NOT NULL, + dedup_key text, + dedup_strategy text, + max_attempts integer DEFAULT 10, + parent_id bigint, + parent_state jsonb, + suspended boolean DEFAULT false NOT NULL, + claimed_by uuid, + rate_limit_key text, + rate_limit_prefix text, + throttled_until timestamp with time zone, + concurrency_key text, + concurrency_prefix text, + rate_limit_cost double precision DEFAULT 1 NOT NULL, + cancel_requested_at timestamp with time zone, + archive_for integer +) +WITH (fillfactor='100'); + + +ALTER TABLE arbiter.meetings OWNER TO "wire-server"; + +-- +-- Name: meetings_archive; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.meetings_archive ( + id bigint NOT NULL, + completed_at timestamp with time zone DEFAULT now() NOT NULL, + archive_expires_at timestamp with time zone NOT NULL, + job_id bigint NOT NULL, + claimed_by uuid, + archive_for integer, + rate_limit_key text, + rate_limit_prefix text, + rate_limit_cost double precision, + concurrency_key text, + concurrency_prefix text, + result jsonb, + payload jsonb NOT NULL, + group_key text, + inserted_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone, + last_attempted_at timestamp with time zone, + not_visible_until timestamp with time zone, + attempts integer DEFAULT 0 NOT NULL, + last_error text, + priority integer DEFAULT 0 NOT NULL, + dedup_key text, + dedup_strategy text, + max_attempts integer, + parent_id bigint, + parent_state jsonb, + suspended boolean DEFAULT false NOT NULL +); + + +ALTER TABLE arbiter.meetings_archive OWNER TO "wire-server"; + +-- +-- Name: meetings_archive_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server +-- + +CREATE SEQUENCE arbiter.meetings_archive_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE arbiter.meetings_archive_id_seq OWNER TO "wire-server"; + +-- +-- Name: meetings_archive_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server +-- + +ALTER SEQUENCE arbiter.meetings_archive_id_seq OWNED BY arbiter.meetings_archive.id; + + +-- +-- Name: meetings_dlq; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.meetings_dlq ( + id bigint NOT NULL, + failed_at timestamp with time zone DEFAULT now() NOT NULL, + job_id bigint NOT NULL, + payload jsonb NOT NULL, + group_key text, + inserted_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone, + last_attempted_at timestamp with time zone, + not_visible_until timestamp with time zone, + attempts integer DEFAULT 0 NOT NULL, + last_error text, + priority integer DEFAULT 0 NOT NULL, + dedup_key text, + dedup_strategy text, + max_attempts integer, + parent_id bigint, + parent_state jsonb, + suspended boolean DEFAULT false NOT NULL, + claimed_by uuid, + rate_limit_key text, + rate_limit_prefix text, + concurrency_key text, + concurrency_prefix text, + rate_limit_cost double precision DEFAULT 1 NOT NULL, + archive_for integer +); + + +ALTER TABLE arbiter.meetings_dlq OWNER TO "wire-server"; + +-- +-- Name: meetings_dlq_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server +-- + +CREATE SEQUENCE arbiter.meetings_dlq_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE arbiter.meetings_dlq_id_seq OWNER TO "wire-server"; + +-- +-- Name: meetings_dlq_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server +-- + +ALTER SEQUENCE arbiter.meetings_dlq_id_seq OWNED BY arbiter.meetings_dlq.id; + + +-- +-- Name: meetings_groups; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.meetings_groups ( + group_key text NOT NULL, + min_priority integer DEFAULT 0 NOT NULL, + min_id bigint DEFAULT 0 NOT NULL, + job_count integer DEFAULT 0 NOT NULL, + in_flight_until timestamp with time zone, + ready_count integer DEFAULT 0 NOT NULL, + next_due timestamp with time zone +); + + +ALTER TABLE arbiter.meetings_groups OWNER TO "wire-server"; + +-- +-- Name: meetings_id_seq; Type: SEQUENCE; Schema: arbiter; Owner: wire-server +-- + +CREATE SEQUENCE arbiter.meetings_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE arbiter.meetings_id_seq OWNER TO "wire-server"; + +-- +-- Name: meetings_id_seq; Type: SEQUENCE OWNED BY; Schema: arbiter; Owner: wire-server +-- + +ALTER SEQUENCE arbiter.meetings_id_seq OWNED BY arbiter.meetings.id; + + +-- +-- Name: meetings_results; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.meetings_results ( + parent_id bigint NOT NULL, + child_id bigint NOT NULL, + result jsonb NOT NULL +); + + +ALTER TABLE arbiter.meetings_results OWNER TO "wire-server"; + +-- +-- Name: schema_migrations; Type: TABLE; Schema: arbiter; Owner: wire-server +-- + +CREATE TABLE arbiter.schema_migrations ( + filename character varying(512) NOT NULL, + checksum character varying(32) NOT NULL, + executed_at timestamp without time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE arbiter.schema_migrations OWNER TO "wire-server"; + -- -- Name: apps; Type: TABLE; Schema: public; Owner: wire-server -- @@ -303,6 +1471,8 @@ CREATE TABLE public.meetings ( trial boolean DEFAULT false NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, + tzid text NOT NULL, + CONSTRAINT meetings_recurrence_consistency CHECK ((((recurrence_frequency IS NULL) AND (recurrence_interval IS NULL) AND (recurrence_until IS NULL)) OR ((recurrence_frequency IS NOT NULL) AND (recurrence_interval IS NOT NULL)))), CONSTRAINT meetings_title_length CHECK ((length(title) <= 256)), CONSTRAINT meetings_title_not_empty CHECK ((length(TRIM(BOTH FROM title)) > 0)), CONSTRAINT meetings_valid_time_range CHECK ((end_time > start_time)) @@ -478,7 +1648,193 @@ CREATE TABLE public.wire_user ( ); -ALTER TABLE public.wire_user OWNER TO "wire-server"; +ALTER TABLE public.wire_user OWNER TO "wire-server"; + +-- +-- Name: conversations id; Type: DEFAULT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations ALTER COLUMN id SET DEFAULT nextval('arbiter.conversations_id_seq'::regclass); + + +-- +-- Name: conversations_archive id; Type: DEFAULT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations_archive ALTER COLUMN id SET DEFAULT nextval('arbiter.conversations_archive_id_seq'::regclass); + + +-- +-- Name: conversations_dlq id; Type: DEFAULT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations_dlq ALTER COLUMN id SET DEFAULT nextval('arbiter.conversations_dlq_id_seq'::regclass); + + +-- +-- Name: meetings id; Type: DEFAULT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings ALTER COLUMN id SET DEFAULT nextval('arbiter.meetings_id_seq'::regclass); + + +-- +-- Name: meetings_archive id; Type: DEFAULT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings_archive ALTER COLUMN id SET DEFAULT nextval('arbiter.meetings_archive_id_seq'::regclass); + + +-- +-- Name: meetings_dlq id; Type: DEFAULT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings_dlq ALTER COLUMN id SET DEFAULT nextval('arbiter.meetings_dlq_id_seq'::regclass); + + +-- +-- Name: arbiter_concurrency arbiter_concurrency_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.arbiter_concurrency + ADD CONSTRAINT arbiter_concurrency_pkey PRIMARY KEY (concurrency_key); + + +-- +-- Name: arbiter_concurrency_policies arbiter_concurrency_policies_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.arbiter_concurrency_policies + ADD CONSTRAINT arbiter_concurrency_policies_pkey PRIMARY KEY (prefix_id); + + +-- +-- Name: arbiter_gates arbiter_gates_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.arbiter_gates + ADD CONSTRAINT arbiter_gates_pkey PRIMARY KEY (task_name); + + +-- +-- Name: arbiter_queues arbiter_queues_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.arbiter_queues + ADD CONSTRAINT arbiter_queues_pkey PRIMARY KEY (queue_name); + + +-- +-- Name: arbiter_rate_limit_policies arbiter_rate_limit_policies_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.arbiter_rate_limit_policies + ADD CONSTRAINT arbiter_rate_limit_policies_pkey PRIMARY KEY (prefix_id); + + +-- +-- Name: arbiter_rate_limits arbiter_rate_limits_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.arbiter_rate_limits + ADD CONSTRAINT arbiter_rate_limits_pkey PRIMARY KEY (rate_limit_key); + + +-- +-- Name: arbiter_workers arbiter_workers_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.arbiter_workers + ADD CONSTRAINT arbiter_workers_pkey PRIMARY KEY (worker_id); + + +-- +-- Name: conversations_archive conversations_archive_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations_archive + ADD CONSTRAINT conversations_archive_pkey PRIMARY KEY (id); + + +-- +-- Name: conversations_dlq conversations_dlq_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations_dlq + ADD CONSTRAINT conversations_dlq_pkey PRIMARY KEY (id); + + +-- +-- Name: conversations_groups conversations_groups_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations_groups + ADD CONSTRAINT conversations_groups_pkey PRIMARY KEY (group_key); + + +-- +-- Name: conversations conversations_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations + ADD CONSTRAINT conversations_pkey PRIMARY KEY (id); + + +-- +-- Name: conversations_results conversations_results_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations_results + ADD CONSTRAINT conversations_results_pkey PRIMARY KEY (parent_id, child_id); + + +-- +-- Name: cron_schedules cron_schedules_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.cron_schedules + ADD CONSTRAINT cron_schedules_pkey PRIMARY KEY (name); + + +-- +-- Name: meetings_archive meetings_archive_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings_archive + ADD CONSTRAINT meetings_archive_pkey PRIMARY KEY (id); + + +-- +-- Name: meetings_dlq meetings_dlq_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings_dlq + ADD CONSTRAINT meetings_dlq_pkey PRIMARY KEY (id); + + +-- +-- Name: meetings_groups meetings_groups_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings_groups + ADD CONSTRAINT meetings_groups_pkey PRIMARY KEY (group_key); + + +-- +-- Name: meetings meetings_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings + ADD CONSTRAINT meetings_pkey PRIMARY KEY (id); + + +-- +-- Name: meetings_results meetings_results_pkey; Type: CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings_results + ADD CONSTRAINT meetings_results_pkey PRIMARY KEY (parent_id, child_id); + -- -- Name: apps apps_pkey; Type: CONSTRAINT; Schema: public; Owner: wire-server @@ -672,6 +2028,265 @@ ALTER TABLE ONLY public.wire_user ADD CONSTRAINT wire_user_pkey PRIMARY KEY (id); +-- +-- Name: conversations_adminless_team_id_idx; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX conversations_adminless_team_id_idx ON arbiter.conversations USING btree (((payload #>> '{data,team_id}'::text[]))) WHERE ((claimed_by IS NULL) AND ((payload ->> 'type'::text) = ANY (ARRAY['adminless_setup'::text, 'adminless_deletion'::text, 'adminless_reminder'::text]))); + + +-- +-- Name: idx_conversations_archive_completed_at; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_archive_completed_at ON arbiter.conversations_archive USING btree (completed_at DESC); + + +-- +-- Name: idx_conversations_archive_expires_at; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_archive_expires_at ON arbiter.conversations_archive USING btree (archive_expires_at); + + +-- +-- Name: idx_conversations_archive_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_archive_group_key ON arbiter.conversations_archive USING btree (group_key); + + +-- +-- Name: idx_conversations_archive_job_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_archive_job_id ON arbiter.conversations_archive USING btree (job_id); + + +-- +-- Name: idx_conversations_archive_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_archive_parent_id ON arbiter.conversations_archive USING btree (parent_id) WHERE (parent_id IS NOT NULL); + + +-- +-- Name: idx_conversations_cancel_requested; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_cancel_requested ON arbiter.conversations USING btree (id) WHERE (cancel_requested_at IS NOT NULL); + + +-- +-- Name: idx_conversations_concurrency; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_concurrency ON arbiter.conversations USING btree (concurrency_key) WHERE (concurrency_key IS NOT NULL); + + +-- +-- Name: idx_conversations_dedup_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE UNIQUE INDEX idx_conversations_dedup_key ON arbiter.conversations USING btree (dedup_key) WHERE (dedup_key IS NOT NULL); + + +-- +-- Name: idx_conversations_dlq_failed_at; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_dlq_failed_at ON arbiter.conversations_dlq USING btree (failed_at DESC); + + +-- +-- Name: idx_conversations_dlq_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_dlq_group_key ON arbiter.conversations_dlq USING btree (group_key); + + +-- +-- Name: idx_conversations_dlq_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_dlq_parent_id ON arbiter.conversations_dlq USING btree (parent_id) WHERE (parent_id IS NOT NULL); + + +-- +-- Name: idx_conversations_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_group_key ON arbiter.conversations USING btree (group_key, priority, id) WHERE (group_key IS NOT NULL); + + +-- +-- Name: idx_conversations_groups_next_due; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_groups_next_due ON arbiter.conversations_groups USING btree (next_due) WHERE (next_due IS NOT NULL); + + +-- +-- Name: idx_conversations_groups_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_groups_ranking ON arbiter.conversations_groups USING btree (min_priority, min_id) WHERE ((ready_count > 0) AND (in_flight_until IS NULL)); + + +-- +-- Name: idx_conversations_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_parent_id ON arbiter.conversations USING btree (parent_id) WHERE (parent_id IS NOT NULL); + + +-- +-- Name: idx_conversations_throttled; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_throttled ON arbiter.conversations USING btree (rate_limit_prefix, rate_limit_key) WHERE (throttled_until IS NOT NULL); + + +-- +-- Name: idx_conversations_ungrouped_due; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_ungrouped_due ON arbiter.conversations USING btree (not_visible_until) WHERE ((group_key IS NULL) AND (not_visible_until IS NOT NULL) AND (NOT suspended)); + + +-- +-- Name: idx_conversations_ungrouped_ready_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_conversations_ungrouped_ready_ranking ON arbiter.conversations USING btree (priority, id) WHERE ((group_key IS NULL) AND (not_visible_until IS NULL) AND (NOT suspended)); + + +-- +-- Name: idx_meetings_archive_completed_at; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_archive_completed_at ON arbiter.meetings_archive USING btree (completed_at DESC); + + +-- +-- Name: idx_meetings_archive_expires_at; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_archive_expires_at ON arbiter.meetings_archive USING btree (archive_expires_at); + + +-- +-- Name: idx_meetings_archive_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_archive_group_key ON arbiter.meetings_archive USING btree (group_key); + + +-- +-- Name: idx_meetings_archive_job_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_archive_job_id ON arbiter.meetings_archive USING btree (job_id); + + +-- +-- Name: idx_meetings_archive_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_archive_parent_id ON arbiter.meetings_archive USING btree (parent_id) WHERE (parent_id IS NOT NULL); + + +-- +-- Name: idx_meetings_cancel_requested; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_cancel_requested ON arbiter.meetings USING btree (id) WHERE (cancel_requested_at IS NOT NULL); + + +-- +-- Name: idx_meetings_concurrency; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_concurrency ON arbiter.meetings USING btree (concurrency_key) WHERE (concurrency_key IS NOT NULL); + + +-- +-- Name: idx_meetings_dedup_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE UNIQUE INDEX idx_meetings_dedup_key ON arbiter.meetings USING btree (dedup_key) WHERE (dedup_key IS NOT NULL); + + +-- +-- Name: idx_meetings_dlq_failed_at; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_dlq_failed_at ON arbiter.meetings_dlq USING btree (failed_at DESC); + + +-- +-- Name: idx_meetings_dlq_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_dlq_group_key ON arbiter.meetings_dlq USING btree (group_key); + + +-- +-- Name: idx_meetings_dlq_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_dlq_parent_id ON arbiter.meetings_dlq USING btree (parent_id) WHERE (parent_id IS NOT NULL); + + +-- +-- Name: idx_meetings_group_key; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_group_key ON arbiter.meetings USING btree (group_key, priority, id) WHERE (group_key IS NOT NULL); + + +-- +-- Name: idx_meetings_groups_next_due; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_groups_next_due ON arbiter.meetings_groups USING btree (next_due) WHERE (next_due IS NOT NULL); + + +-- +-- Name: idx_meetings_groups_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_groups_ranking ON arbiter.meetings_groups USING btree (min_priority, min_id) WHERE ((ready_count > 0) AND (in_flight_until IS NULL)); + + +-- +-- Name: idx_meetings_parent_id; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_parent_id ON arbiter.meetings USING btree (parent_id) WHERE (parent_id IS NOT NULL); + + +-- +-- Name: idx_meetings_throttled; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_throttled ON arbiter.meetings USING btree (rate_limit_prefix, rate_limit_key) WHERE (throttled_until IS NOT NULL); + + +-- +-- Name: idx_meetings_ungrouped_due; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_ungrouped_due ON arbiter.meetings USING btree (not_visible_until) WHERE ((group_key IS NULL) AND (not_visible_until IS NOT NULL) AND (NOT suspended)); + + +-- +-- Name: idx_meetings_ungrouped_ready_ranking; Type: INDEX; Schema: arbiter; Owner: wire-server +-- + +CREATE INDEX idx_meetings_ungrouped_ready_ranking ON arbiter.meetings USING btree (priority, id) WHERE ((group_key IS NULL) AND (not_visible_until IS NULL) AND (NOT suspended)); + + -- -- Name: asset_user_id_idx; Type: INDEX; Schema: public; Owner: wire-server -- @@ -784,6 +2399,20 @@ CREATE INDEX idx_meetings_creator ON public.meetings USING btree (creator); CREATE INDEX idx_meetings_end_time ON public.meetings USING btree (end_time); +-- +-- Name: idx_meetings_end_time_nonrecurring; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_meetings_end_time_nonrecurring ON public.meetings USING btree (end_time) WHERE (recurrence_frequency IS NULL); + + +-- +-- Name: idx_meetings_recurrence_eff_end; Type: INDEX; Schema: public; Owner: wire-server +-- + +CREATE INDEX idx_meetings_recurrence_eff_end ON public.meetings USING btree (GREATEST(end_time, recurrence_until)) WHERE ((recurrence_frequency IS NOT NULL) AND (recurrence_interval IS NOT NULL) AND (recurrence_until IS NOT NULL)); + + -- -- Name: idx_meetings_start_time; Type: INDEX; Schema: public; Owner: wire-server -- @@ -805,6 +2434,62 @@ CREATE INDEX user_group_member_user_id_idx ON public.user_group_member USING btr CREATE INDEX wire_user_service_idx ON public.wire_user USING btree (provider, service); +-- +-- Name: conversations conversations_notify_trigger; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER conversations_notify_trigger AFTER INSERT ON arbiter.conversations FOR EACH ROW EXECUTE FUNCTION arbiter.notify_conversations_created(); + + +-- +-- Name: conversations maintain_conversations_groups_delete; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER maintain_conversations_groups_delete AFTER DELETE ON arbiter.conversations REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_conversations_groups_delete(); + + +-- +-- Name: conversations maintain_conversations_groups_insert; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER maintain_conversations_groups_insert AFTER INSERT ON arbiter.conversations REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_conversations_groups_insert(); + + +-- +-- Name: conversations maintain_conversations_groups_update; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER maintain_conversations_groups_update AFTER UPDATE ON arbiter.conversations REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_conversations_groups_update(); + + +-- +-- Name: meetings maintain_meetings_groups_delete; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER maintain_meetings_groups_delete AFTER DELETE ON arbiter.meetings REFERENCING OLD TABLE AS old_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_meetings_groups_delete(); + + +-- +-- Name: meetings maintain_meetings_groups_insert; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER maintain_meetings_groups_insert AFTER INSERT ON arbiter.meetings REFERENCING NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_meetings_groups_insert(); + + +-- +-- Name: meetings maintain_meetings_groups_update; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER maintain_meetings_groups_update AFTER UPDATE ON arbiter.meetings REFERENCING OLD TABLE AS old_table NEW TABLE AS new_table FOR EACH STATEMENT EXECUTE FUNCTION arbiter.maintain_meetings_groups_update(); + + +-- +-- Name: meetings meetings_notify_trigger; Type: TRIGGER; Schema: arbiter; Owner: wire-server +-- + +CREATE TRIGGER meetings_notify_trigger AFTER INSERT ON arbiter.meetings FOR EACH ROW EXECUTE FUNCTION arbiter.notify_meetings_created(); + + -- -- Name: wire_user update_user_updated_at; Type: TRIGGER; Schema: public; Owner: wire-server -- @@ -812,6 +2497,22 @@ CREATE INDEX wire_user_service_idx ON public.wire_user USING btree (provider, se CREATE TRIGGER update_user_updated_at BEFORE UPDATE ON public.wire_user FOR EACH ROW EXECUTE FUNCTION public.update_updated_at(); +-- +-- Name: conversations_results conversations_results_parent_id_fkey; Type: FK CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.conversations_results + ADD CONSTRAINT conversations_results_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES arbiter.conversations(id) ON DELETE CASCADE; + + +-- +-- Name: meetings_results meetings_results_parent_id_fkey; Type: FK CONSTRAINT; Schema: arbiter; Owner: wire-server +-- + +ALTER TABLE ONLY arbiter.meetings_results + ADD CONSTRAINT meetings_results_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES arbiter.meetings(id) ON DELETE CASCADE; + + -- -- Name: bot_conv bot_conv_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: wire-server -- diff --git a/renovate.json b/renovate.json index d993844ca33..6472da35fc8 100644 --- a/renovate.json +++ b/renovate.json @@ -37,6 +37,20 @@ ] } ], + "customManagers": [ + { + "customType": "regex", + "managerFilePatterns": [ + "/charts/.+/values\\.yaml$/" + ], + "matchStrings": [ + "# renovate: datasource=(?\\S+) depName=(?\\S+)\\s+[^\\n]*\\n\\s*repository:[^\\n]*\\n\\s*tag:\\s*[\"']?(?[^\"'\\s]+)[\"']?" + ], + "datasourceTemplate": "{{{datasource}}}", + "depNameTemplate": "{{{depName}}}", + "versioningTemplate": "docker" + } + ], "vulnerabilityAlerts": { "enabled": true }, diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index a3943555eb7..a43796a4ca7 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -12,6 +12,7 @@ build-type: Simple library -- cabal-fmt: expand src exposed-modules: + Wire.AdminlessJobsWorker Wire.BackendNotificationPusher Wire.BackgroundWorker Wire.BackgroundWorker.Env @@ -20,6 +21,7 @@ library Wire.BackgroundWorker.Jobs.Registry Wire.BackgroundWorker.Options Wire.BackgroundWorker.Util + Wire.BackgroundWorker.Workers Wire.DeadUserNotificationWatcher Wire.Effects Wire.MeetingsCleanupWorker @@ -35,6 +37,8 @@ library build-depends: aeson , amqp + , arbiter-core + , arbiter-worker , base , bilge , bytestring @@ -47,7 +51,7 @@ library , extended , extra , galley-types - , hasql-pool + , hasql-resource-pool , HsOpenSSL , http-client , http2-manager diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index d794fbb0cc5..e264ce14016 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -33,7 +33,6 @@ cassandraBrig: postgresqlPool: size: 5 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m rabbitmq: @@ -66,6 +65,21 @@ backgroundJobs: jobTimeout: 5s maxAttempts: 3 +# Job dispatcher configuration for integration +jobs: + pollInterval: 1s # Poll every second so due jobs are discovered promptly in tests + workerThreads: 1 # Keep one worker per job queue in integration tests + visibilityTimeout: 60s + jobHeartbeatInterval: 30s + workerHeartbeatInterval: 10s + backoffBase: 2.0 + backoffCap: 86400s + jitter: equal + gracefulShutdownTimeout: 30s + reaperInterval: 300s + reaperTimeout: 300s + workerStaleThreshold: 300s + # Meetings cleanup configuration for integration meetingsCleanup: cleanOlderThanHours: 0.0014 # Clean meetings older than ~5 seconds diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index 1b08c073784..acf79b6e195 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -5,6 +5,8 @@ { mkDerivation , aeson , amqp +, arbiter-core +, arbiter-worker , base , bilge , bytestring @@ -19,7 +21,7 @@ , extra , federator , galley-types -, hasql-pool +, hasql-resource-pool , HsOpenSSL , hspec , http-client @@ -66,6 +68,8 @@ mkDerivation { libraryHaskellDepends = [ aeson amqp + arbiter-core + arbiter-worker base bilge bytestring @@ -78,7 +82,7 @@ mkDerivation { extended extra galley-types - hasql-pool + hasql-resource-pool HsOpenSSL http-client http2-manager diff --git a/services/background-worker/src/Wire/AdminlessJobsWorker.hs b/services/background-worker/src/Wire/AdminlessJobsWorker.hs new file mode 100644 index 00000000000..573f7398f54 --- /dev/null +++ b/services/background-worker/src/Wire/AdminlessJobsWorker.hs @@ -0,0 +1,105 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +module Wire.AdminlessJobsWorker + ( runAdminlessSetupJob, + runAdminlessDeletionJob, + runAdminlessReminderJob, + ) +where + +import Arbiter.Core.Exceptions (throwRetryable) +import Arbiter.Core.Job.Types (JobRead, notVisibleUntil, payload) +import Data.Qualified (toLocalUnsafe) +import Imports +import System.Logger qualified as Log +import Wire.API.Jobs (AdminlessDeletionJob (..), AdminlessReminderJob (..), AdminlessSetupJob (..)) +import Wire.BackgroundWorker.Env (AppT, Env (..)) +import Wire.ConversationSubsystem +import Wire.Effects (runBackgroundWorkerEffects) +import Wire.ExternalAccess.External (ExtEnv) + +runAdminlessSetupJob :: ExtEnv -> JobRead AdminlessSetupJob -> AppT IO () +runAdminlessSetupJob extEnv job = do + env <- ask + Log.debug env.logger $ + Log.msg (Log.val "Running adminless setup job") + . Log.field "team_id" (show job.payload.adminlessSetupJobTeamId) + . Log.field "orig_user_id" (show job.payload.adminlessSetupJobOrigUserId) + . Log.field "request_id" (show job.payload.adminlessSetupJobRequestId) + . Log.field "scheduled_for" (show job.notVisibleUntil) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv job.payload.adminlessSetupJobRequestId Nothing $ + do + setupAdminlessGroupsCleanup + (toLocalUnsafe env.federationDomain <$> job.payload.adminlessSetupJobOrigUserId) + job.payload.adminlessSetupJobTeamId + Log.debug env.logger $ + Log.msg (Log.val "Adminless setup job finished") + . Log.field "team_id" (show job.payload.adminlessSetupJobTeamId) + either (liftIO . throwRetryable) pure result + +runAdminlessDeletionJob :: ExtEnv -> JobRead AdminlessDeletionJob -> AppT IO () +runAdminlessDeletionJob extEnv job = do + env <- ask + Log.debug env.logger $ + Log.msg (Log.val "Running adminless deletion job") + . Log.field "team_id" (show job.payload.adminlessDeletionJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessDeletionJobConversationId) + . Log.field "orig_user_id" (show job.payload.adminlessDeletionJobOrigUserId) + . Log.field "request_id" (show job.payload.adminlessDeletionJobRequestId) + . Log.field "scheduled_for" (show job.notVisibleUntil) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv job.payload.adminlessDeletionJobRequestId Nothing $ + do + Log.debug env.logger $ + Log.msg (Log.val "Adminless deletion job: invoking conversation delete") + . Log.field "team_id" (show job.payload.adminlessDeletionJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessDeletionJobConversationId) + internalDeleteLocalAdminlessGroup + (toLocalUnsafe env.federationDomain <$> job.payload.adminlessDeletionJobOrigUserId) + (toLocalUnsafe env.federationDomain job.payload.adminlessDeletionJobConversationId) + Log.debug env.logger $ + Log.msg (Log.val "Adminless deletion job finished") + . Log.field "team_id" (show job.payload.adminlessDeletionJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessDeletionJobConversationId) + either (liftIO . throwRetryable) pure result + +runAdminlessReminderJob :: ExtEnv -> JobRead AdminlessReminderJob -> AppT IO () +runAdminlessReminderJob extEnv job = do + env <- ask + Log.debug env.logger $ + Log.msg (Log.val "Running adminless reminder job") + . Log.field "team_id" (show job.payload.adminlessReminderJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessReminderJobConversationId) + . Log.field "request_id" (show job.payload.adminlessReminderJobRequestId) + . Log.field "deletion_scheduled_for" (show job.payload.adminlessReminderJobDeletionScheduledFor) + result <- + liftIO $ + runBackgroundWorkerEffects env extEnv job.payload.adminlessReminderJobRequestId Nothing $ + do + internalNotifyAdminlessReminder + (toLocalUnsafe env.federationDomain <$> job.payload.adminlessReminderJobOrigUserId) + (toLocalUnsafe env.federationDomain job.payload.adminlessReminderJobConversationId) + job.payload.adminlessReminderJobDeletionScheduledFor + Log.debug env.logger $ + Log.msg (Log.val "Adminless reminder job finished") + . Log.field "team_id" (show job.payload.adminlessReminderJobTeamId) + . Log.field "conversation_id" (show job.payload.adminlessReminderJobConversationId) + either (liftIO . throwRetryable) pure result diff --git a/services/background-worker/src/Wire/BackgroundWorker.hs b/services/background-worker/src/Wire/BackgroundWorker.hs index fd145cf4668..b57ba12df40 100644 --- a/services/background-worker/src/Wire/BackgroundWorker.hs +++ b/services/background-worker/src/Wire/BackgroundWorker.hs @@ -33,8 +33,8 @@ import Wire.BackgroundWorker.Env import Wire.BackgroundWorker.Health qualified as Health import Wire.BackgroundWorker.Jobs.Consumer qualified as Jobs import Wire.BackgroundWorker.Options +import Wire.BackgroundWorker.Workers as Workers import Wire.DeadUserNotificationWatcher qualified as DeadUserNotificationWatcher -import Wire.MeetingsCleanupWorker qualified as MeetingsCleanupWorker import Wire.Options.Galley qualified as Galley import Wire.PostgresMigrations qualified as Migrations @@ -82,10 +82,10 @@ run opts galleyOpts = do runAppT env $ withNamedLogger "background-job-consumer" $ Jobs.startWorker amqpEP - cleanupMeetings <- + cleanupJobRunner <- runAppT env $ - withNamedLogger "meetings-cleanup" $ - MeetingsCleanupWorker.startWorker opts.meetingsCleanup + withNamedLogger "job-runner" $ + Workers.startWorker opts.jobs opts.meetingsCleanup let cleanup = void $ runConcurrently $ @@ -96,8 +96,8 @@ run opts galleyOpts = do <*> Concurrently cleanUpConvCodesMigration <*> Concurrently cleanupTeamFeaturesMigration <*> Concurrently cleanupDomainRegistrationMigration + <*> Concurrently cleanupJobRunner <*> Concurrently cleanupJobs - <*> Concurrently cleanupMeetings let server = defaultServer (T.unpack opts.backgroundWorker.host) opts.backgroundWorker.port env.logger let settings = newSettings server diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index 6c514afb7de..ed784a33db9 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -29,9 +29,10 @@ import Data.Domain (Domain) import Data.Id (TeamId) import Data.Map.Strict qualified as Map import Data.Misc (HttpsUrl) +import Data.Secret (SecretText) import HTTP2.Client.Manager -import Hasql.Pool qualified as Hasql import Hasql.Pool.Extended +import Hasql.Pool.Extended qualified as Hasql import Imports import Network.AMQP qualified as Q import Network.AMQP.Extended @@ -50,6 +51,7 @@ import Wire.API.Conversation.Protocol (ProtocolTag) import Wire.API.Team.Feature (LegalholdConfig, npProject) import Wire.API.Team.FeatureFlags (FanoutLimit, FeatureFlags) import Wire.BackgroundWorker.Options +import Wire.JobSubsystem.Migrations (mkArbiterConnectionString) import Wire.Options.Galley (GuestLinkTTLSeconds, conversationCodeURISettings) import Wire.Options.Galley qualified as Galley import Wire.Options.Keys (loadAllMLSKeys) @@ -89,6 +91,8 @@ data Env = Env cassandraGalley :: ClientState, cassandraBrig :: ClientState, hasqlPool :: Hasql.Pool, + -- May contain the PostgreSQL password. Do not unwrap outside the Arbiter boundary. + arbiterConnStr :: SecretText, -- Dedicated AMQP channels per concern amqpJobsPublisherChannel :: MVar Q.Channel, amqpBackendNotificationsChannel :: MVar Q.Channel, @@ -108,7 +112,7 @@ data Env = Env guestLinkTTLSeconds :: !(Maybe GuestLinkTTLSeconds), passwordHashingOptions :: !PasswordHashingOptions, checkGroupInfo :: !(Maybe Bool), - convCodeURI :: Either HttpsUrl (Map Text HttpsUrl), + convCodeURI :: Either HttpsUrl (Map Domain HttpsUrl), passwordHashingRateLimitEnv :: RateLimitEnv } @@ -190,6 +194,7 @@ mkEnv opts galleyOpts = do checkGroupInfo = galleyOpts._settings._checkGroupInfo workerRunningGauge <- mkWorkerRunningGauge hasqlPool <- initPostgresPool opts.postgresqlPool galleyOpts._postgresql galleyOpts._postgresqlPassword + arbiterConnStr <- mkArbiterConnectionString galleyOpts._postgresql galleyOpts._postgresqlPassword Log.info logger $ Log.msg @Text "Opening RabbitMQ channel: background-worker-jobs-publisher..." amqpJobsPublisherChannel <- mkRabbitMqChannelMVar logger (Just "background-worker-jobs-publisher") $ diff --git a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs index dbd697c5bef..cb0d8a4b9c6 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Consumer.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.BackgroundWorker.Jobs.Consumer (startWorker, BackgroundJobsMetrics (..)) where +module Wire.BackgroundWorker.Jobs.Consumer (startWorker, BackgroundJobMetrics (..)) where import Control.Concurrent.Timeout qualified as Timeout import Control.Retry @@ -39,7 +39,7 @@ import Wire.BackgroundWorker.Jobs.Registry import Wire.BackgroundWorker.Options import Wire.BackgroundWorker.Util (CleanupAction) -data BackgroundJobsMetrics = BackgroundJobsMetrics +data BackgroundJobMetrics = BackgroundJobMetrics { workersBusy :: Gauge, concurrencyConfigured :: Gauge, jobsReceived :: Vector Text Counter, @@ -51,7 +51,7 @@ data BackgroundJobsMetrics = BackgroundJobsMetrics jobDuration :: Vector Text Histogram } -mkMetrics :: IO BackgroundJobsMetrics +mkMetrics :: IO BackgroundJobMetrics mkMetrics = do workersBusy <- register (gauge $ Info {metricName = "wire_background_jobs_workers_busy", metricHelp = "In-flight background jobs"}) concurrencyConfigured <- register (gauge $ Info {metricName = "wire_background_jobs_concurrency_configured", metricHelp = "Configured concurrency for this process"}) @@ -62,7 +62,7 @@ mkMetrics = do jobsInvalid <- register (vector "job_type" $ counter $ Info "wire_background_jobs_invalid_total" "Invalid jobs received") jobsRedelivered <- register (vector "job_type" $ counter $ Info "wire_background_jobs_redelivered_total" "Jobs marked redelivered by broker") jobDuration <- register (vector "job_type" $ histogram (Info "wire_background_jobs_duration_seconds" "Job duration seconds") defaultBuckets) - pure BackgroundJobsMetrics {..} + pure BackgroundJobMetrics {..} startWorker :: AmqpEndpoint -> AppT IO CleanupAction startWorker rabbitmqOpts = do @@ -107,16 +107,16 @@ startWorker rabbitmqOpts = do Log.info $ Log.msg (Log.val "Background job consumer cleanup") markAsNotWorking BackgroundJobConsumer -handleDelivery :: BackgroundJobsMetrics -> BackgroundJobsConfig -> (Q.Message, Q.Envelope) -> AppT IO () +handleDelivery :: BackgroundJobMetrics -> BackgroundJobsConfig -> (Q.Message, Q.Envelope) -> AppT IO () handleDelivery metrics cfg (msg, env) = do - case Aeson.eitherDecode @Job (Q.msgBody msg) of + case Aeson.eitherDecode @BackgroundJob (Q.msgBody msg) of Left err -> do withLabel metrics.jobsInvalid "invalid" incCounter Log.err $ Log.msg (Log.val "Invalid background job JSON") . Log.field "error" err Timeout.threadDelay (200 # MilliSecond) -- avoid tight redelivery loop liftIO $ Q.rejectEnv env True Right job -> do - let lbl = jobPayloadLabel job.payload + let lbl = backgroundJobPayloadLabel job.payload when (Q.envRedelivered env) $ withLabel metrics.jobsRedelivered lbl incCounter withLabel metrics.jobsReceived lbl incCounter UnliftIO.bracket_ (incGauge metrics.workersBusy) (decGauge metrics.workersBusy) $ do @@ -130,7 +130,7 @@ handleDelivery metrics cfg (msg, env) = do Log.err $ Log.msg (Log.val "Background job failed after retries") . Log.field "error" e liftIO $ Q.rejectEnv env False where - runAttempts :: Text -> Job -> AppT IO (Either Text ()) + runAttempts :: Text -> BackgroundJob -> AppT IO (Either Text ()) runAttempts lbl job = do let retries = max 0 (fromRange cfg.maxAttempts - 1) policy = limitRetries retries <> fullJitterBackoff 100000 -- 100ms base diff --git a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs index 7e4bbcc0648..abb737a95a0 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Jobs/Registry.hs @@ -21,21 +21,21 @@ module Wire.BackgroundWorker.Jobs.Registry where import Imports -import Wire.API.BackgroundJobs (Job (..)) -import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobsPublisherRabbitMQ) +import Wire.API.BackgroundJobs (BackgroundJob (..)) +import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ) import Wire.BackgroundJobsRunner (runJob) -import Wire.BackgroundJobsRunner.Interpreter hiding (runJob) +import Wire.BackgroundJobsRunner.Interpreter (interpretBackgroundJobRunner) import Wire.BackgroundWorker.Env (AppT, Env (..)) import Wire.Effects import Wire.ExternalAccess.External -dispatchJob :: Job -> AppT IO (Either Text ()) +dispatchJob :: BackgroundJob -> AppT IO (Either Text ()) dispatchJob job = do env <- ask @Env let disableTlsV1 = True extEnv <- liftIO (initExtEnv disableTlsV1) liftIO $ runBackgroundWorkerEffects env extEnv job.requestId (Just job.jobId) - . interpretBackgroundJobsPublisherRabbitMQ job.requestId env.amqpJobsPublisherChannel - . interpretBackgroundJobsRunner + . interpretBackgroundJobPublisherRabbitMQ job.requestId env.amqpJobsPublisherChannel + . interpretBackgroundJobRunner $ runJob job diff --git a/services/background-worker/src/Wire/BackgroundWorker/Options.hs b/services/background-worker/src/Wire/BackgroundWorker/Options.hs index f89c52e8300..61df5d5d14f 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Options.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Options.hs @@ -22,7 +22,7 @@ module Wire.BackgroundWorker.Options where import Data.Aeson import Data.Aeson.Types (JSONPathElement (Key), parserThrowError) import Data.Misc -import Data.Range (Range) +import Data.Range (Range, unsafeRange) import GHC.Generics import Hasql.Pool.Extended import Imports @@ -55,6 +55,7 @@ data Opts = Opts migrateConversationCodes :: !Bool, migrateTeamFeatures :: !Bool, migrateDomainRegistration :: !Bool, + jobs :: JobConfig, meetingsCleanup :: MeetingsCleanupConfig, backgroundJobs :: BackgroundJobsConfig } @@ -99,6 +100,83 @@ data BackgroundJobsConfig = BackgroundJobsConfig deriving (Show, Generic) deriving (FromJSON) via Generically BackgroundJobsConfig +data JobConfig = JobConfig + { -- | Arbiter dispatcher poll interval for jobs. + -- Lower values reduce discovery latency for due jobs. + pollInterval :: Duration, + -- | Number of worker threads in each job queue. + workerThreads :: Range 1 1000 Int, + -- | How long a claimed job remains invisible while it is processed. + visibilityTimeout :: Duration, + -- | How often a running job refreshes its visibility timeout. + jobHeartbeatInterval :: Duration, + -- | How often a worker refreshes its own heartbeat. + workerHeartbeatInterval :: Duration, + -- | Base used by Arbiter's exponential retry backoff. + backoffBase :: Double, + -- | Upper bound for Arbiter's exponential retry backoff. + backoffCap :: Duration, + -- | Jitter mode used for retry delays. + jitter :: JobJitter, + -- | Maximum time to wait for in-flight jobs during shutdown. + -- 'Nothing' waits indefinitely. + gracefulShutdownTimeout :: Maybe Duration, + -- | How often the Arbiter reaper runs. + reaperInterval :: Duration, + -- | Maximum time allowed for one reaper pass. + reaperTimeout :: Duration, + -- | How old a worker heartbeat may be before it is considered stale. + workerStaleThreshold :: Duration + } + deriving (Show, Generic) + +data JobJitter + = JobNoJitter + | JobFullJitter + | JobEqualJitter + deriving (Eq, Show, Generic) + +instance FromJSON JobJitter where + parseJSON = withText "JobJitter" $ \case + "none" -> pure JobNoJitter + "full" -> pure JobFullJitter + "equal" -> pure JobEqualJitter + _ -> fail "expected one of: none, full, equal" + +instance FromJSON JobConfig where + parseJSON = + withObject "JobConfig" $ \o -> do + pollInterval <- o .:? "pollInterval" .!= unsafeParseDuration "5s" + workerThreads <- o .:? "workerThreads" .!= unsafeRange 1 + visibilityTimeout <- o .:? "visibilityTimeout" .!= unsafeParseDuration "60s" + jobHeartbeatInterval <- o .:? "jobHeartbeatInterval" .!= unsafeParseDuration "30s" + workerHeartbeatInterval <- o .:? "workerHeartbeatInterval" .!= unsafeParseDuration "10s" + backoffBase <- o .:? "backoffBase" .!= 2.0 + backoffCap <- o .:? "backoffCap" .!= unsafeParseDuration "86400s" + jitter <- o .:? "jitter" .!= JobEqualJitter + gracefulShutdownTimeout <- + o .:? "gracefulShutdownTimeout" .!= Just (unsafeParseDuration "30s") + reaperInterval <- o .:? "reaperInterval" .!= unsafeParseDuration "300s" + reaperTimeout <- o .:? "reaperTimeout" .!= unsafeParseDuration "300s" + workerStaleThreshold <- o .:? "workerStaleThreshold" .!= unsafeParseDuration "300s" + let validatePositive key value = + when (duration value <= 0) $ + parserThrowError [Key key] $ + show key <> " must be greater than 0, got: " <> show value + validatePositive "pollInterval" pollInterval + validatePositive "visibilityTimeout" visibilityTimeout + validatePositive "jobHeartbeatInterval" jobHeartbeatInterval + validatePositive "workerHeartbeatInterval" workerHeartbeatInterval + validatePositive "backoffCap" backoffCap + validatePositive "reaperInterval" reaperInterval + validatePositive "reaperTimeout" reaperTimeout + validatePositive "workerStaleThreshold" workerStaleThreshold + for_ gracefulShutdownTimeout $ validatePositive "gracefulShutdownTimeout" + when (backoffBase <= 0) $ + parserThrowError [Key "backoffBase"] $ + "backoffBase must be greater than 0, got: " <> show backoffBase + pure JobConfig {..} + data MeetingsCleanupConfig = MeetingsCleanupConfig { -- | Delete meetings older than this many hours cleanOlderThanHours :: Double, diff --git a/services/background-worker/src/Wire/BackgroundWorker/Workers.hs b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs new file mode 100644 index 00000000000..e28c1e92d14 --- /dev/null +++ b/services/background-worker/src/Wire/BackgroundWorker/Workers.hs @@ -0,0 +1,288 @@ +{-# LANGUAGE ConstraintKinds #-} +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE ScopedTypeVariables #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +-- FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License +-- for more details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.BackgroundWorker.Workers (startWorker) where + +import Arbiter.Core qualified as ArbiterCore +import Arbiter.Worker qualified as ArbiterWorker +import Arbiter.Worker.Config qualified as ArbiterWorkerConfig +import Arbiter.Worker.Cron qualified as ArbiterWorkerCron +import Control.Exception (throwIO) +import Data.Misc (Duration, duration) +import Data.Range (fromRange) +import Data.Text qualified as T +import Data.Time.Clock (NominalDiffTime) +import Imports +import System.Cron (CronSchedule, serializeCronSchedule) +import System.IO.Error (userError) +import System.Logger qualified as Log +import UnliftIO.Async qualified as Async +import Wire.API.Jobs +import Wire.AdminlessJobsWorker (runAdminlessDeletionJob, runAdminlessReminderJob, runAdminlessSetupJob) +import Wire.BackgroundWorker.Env (AppT, Env (..), runAppT) +import Wire.BackgroundWorker.Options (JobConfig (..), JobJitter (..), MeetingsCleanupConfig (..)) +import Wire.BackgroundWorker.Util +import Wire.ExternalAccess.External +import Wire.JobSubsystem.ArbiterAdapter +import Wire.JobSubsystem.Migrations (runJobMigrations) +import Wire.MeetingsCleanupWorker + +-- | Runtime settings shared by every job runner in a process. +-- +-- These values deliberately mirror the Arbiter worker defaults that we use. +-- Keeping them in one record ensures all job types use the same +-- execution policy as settings are added or tuned. +data JobWorkerSettings = JobWorkerSettings + { jobWorkerThreads :: Int, + jobPollInterval :: NominalDiffTime, + jobVisibilityTimeout :: NominalDiffTime, + jobHeartbeatInterval :: NominalDiffTime, + jobWorkerHeartbeatInterval :: NominalDiffTime, + jobBackoffBase :: Double, + jobBackoffCap :: NominalDiffTime, + jobJitter :: ArbiterWorker.Jitter, + jobGracefulShutdownTimeout :: Maybe NominalDiffTime, + jobReaperInterval :: NominalDiffTime, + jobReaperTimeout :: NominalDiffTime, + jobWorkerStaleThreshold :: NominalDiffTime + } + +data JobRunnerConfig registry = JobRunnerConfig + { jobRunnerLogger :: Log.Logger, + jobRunnerSchedule :: CronSchedule, + jobRunnerSchemaName :: Text, + jobRunnerSettings :: JobWorkerSettings + } + +startWorker :: JobConfig -> MeetingsCleanupConfig -> AppT IO CleanupAction +startWorker scheduledConfig meetingsCleanupConfig = do + env <- ask + extEnv <- liftIO $ initExtEnv True + let cleanupConfig = + CleanupConfig + { retentionHours = meetingsCleanupConfig.cleanOlderThanHours, + batchSize = meetingsCleanupConfig.batchSize + } + workerSettings = + JobWorkerSettings + { jobWorkerThreads = fromRange scheduledConfig.workerThreads, + jobPollInterval = jobDuration scheduledConfig.pollInterval, + jobVisibilityTimeout = jobDuration scheduledConfig.visibilityTimeout, + jobHeartbeatInterval = jobDuration scheduledConfig.jobHeartbeatInterval, + jobWorkerHeartbeatInterval = jobDuration scheduledConfig.workerHeartbeatInterval, + jobBackoffBase = scheduledConfig.backoffBase, + jobBackoffCap = jobDuration scheduledConfig.backoffCap, + jobJitter = toJobJitter scheduledConfig.jitter, + jobGracefulShutdownTimeout = fmap jobDuration scheduledConfig.gracefulShutdownTimeout, + jobReaperInterval = jobDuration scheduledConfig.reaperInterval, + jobReaperTimeout = jobDuration scheduledConfig.reaperTimeout, + jobWorkerStaleThreshold = jobDuration scheduledConfig.workerStaleThreshold + } + workersConfig = + JobRunnerConfig + { jobRunnerLogger = env.logger, + jobRunnerSchedule = meetingsCleanupConfig.schedule, + jobRunnerSchemaName = ArbiterCore.defaultSchemaName, + jobRunnerSettings = workerSettings + } :: + JobRunnerConfig JobRegistry + liftIO $ runJobMigrations env.arbiterConnStr ArbiterCore.defaultSchemaName + liftIO $ runJobRunner env extEnv workersConfig cleanupConfig + +jobDuration :: Duration -> NominalDiffTime +jobDuration = realToFrac . duration + +toJobJitter :: JobJitter -> ArbiterWorker.Jitter +toJobJitter = \case + JobNoJitter -> ArbiterWorker.NoJitter + JobFullJitter -> ArbiterWorker.FullJitter + JobEqualJitter -> ArbiterWorker.EqualJitter + +-- | Start the worker pools for the job queues. +-- +-- Each domain queue has its own Arbiter table and worker pool. The meetings +-- pool owns the recurring cleanup cron job, while the conversations pool owns +-- the adminless one-off jobs. Both pools are supervised by Arbiter's +-- multi-pool runner, so they share the process lifecycle without sharing a +-- payload type or queue. +runJobRunner :: + Env -> + ExtEnv -> + JobRunnerConfig JobRegistry -> + CleanupConfig -> + IO (IO ()) +runJobRunner env extEnv runnerConfig cleanupConfig = do + Log.info runnerConfig.jobRunnerLogger $ + Log.msg (Log.val "Starting job worker") + . Log.field "queue_names" (T.intercalate "," [meetingsQueueName, conversationsQueueName]) + . Log.field "schedule" (show runnerConfig.jobRunnerSchedule) + + let arbiterEnv = mkNewWireArbiterEnv runnerConfig.jobRunnerSchemaName env.hasqlPool + meetingsWorkerHandler _conn job = liftIO $ do + Log.info runnerConfig.jobRunnerLogger $ + Log.msg (Log.val "Running job") + . Log.field "queue_name" meetingsQueueName + . Log.field "payload_type" (meetingsJobPayloadTypeName job.payload) + case job.payload of + MeetingsCleanup _ -> runAppT env $ runCleanupOldMeetings cleanupConfig + + conversationsWorkerHandler _conn job = liftIO $ do + Log.info runnerConfig.jobRunnerLogger $ + Log.msg (Log.val "Running job") + . Log.field "queue_name" conversationsQueueName + . Log.field "payload_type" (conversationsJobPayloadTypeName job.payload) + case job.payload of + AdminlessSetup payload -> runAppT env $ runAdminlessSetupJob extEnv (mapJobPayload (const payload) job) + AdminlessDeletion payload -> runAppT env $ runAdminlessDeletionJob extEnv (mapJobPayload (const payload) job) + AdminlessReminder payload -> runAppT env $ runAdminlessReminderJob extEnv (mapJobPayload (const payload) job) + + cronJob <- case ArbiterWorkerCron.cronJob + "meetings-cleanup" + (serializeCronSchedule runnerConfig.jobRunnerSchedule) + ArbiterWorkerCron.SkipOverlap + ( \_ scheduledFor -> + (ArbiterCore.defaultGroupedJob "meetings-cleanup" (MeetingsCleanup MeetingsCleanupJob)) + { ArbiterCore.notVisibleUntil = Just scheduledFor, + ArbiterCore.maxAttempts = Just 3 + } + ) of + Left err -> throwIO . userError $ "Invalid cron schedule for meetings-cleanup: " <> err + Right job -> pure job + + meetingsWorkerConfig <- + ( ArbiterWorker.transactionalWorkerConfig + runnerConfig.jobRunnerSettings.jobWorkerThreads + meetingsWorkerHandler :: + IO + ( ArbiterWorker.WorkerConfig + (WireArbiter JobRegistry) + MeetingsJobPayload + ) + ) + + conversationsWorkerConfig <- + ( ArbiterWorker.transactionalWorkerConfig + runnerConfig.jobRunnerSettings.jobWorkerThreads + conversationsWorkerHandler :: + IO + ( ArbiterWorker.WorkerConfig + (WireArbiter JobRegistry) + ConversationsJobPayload + ) + ) + + let meetingsWorkerConfig' = + applyExplicitDefaults + runnerConfig.jobRunnerSettings + meetingsWorkerConfig + { ArbiterWorkerConfig.cronJobs = [cronJob] + } + conversationsWorkerConfig' = + applyExplicitDefaults + runnerConfig.jobRunnerSettings + conversationsWorkerConfig + workerPools = + [ ArbiterWorker.namedWorkerPool meetingsWorkerConfig', + ArbiterWorker.namedWorkerPool conversationsWorkerConfig' + ] + + workerAsync <- + Async.async $ + runWireArbiter arbiterEnv $ + ArbiterWorker.runWorkerPools workerPools + + pure $ do + runWireArbiter arbiterEnv $ ArbiterWorker.shutdownPools workerPools + Async.cancel workerAsync + +meetingsJobPayloadTypeName :: MeetingsJobPayload -> Text +meetingsJobPayloadTypeName = \case + MeetingsCleanup _ -> "meetings_cleanup" + +conversationsJobPayloadTypeName :: ConversationsJobPayload -> Text +conversationsJobPayloadTypeName = \case + AdminlessSetup _ -> "adminless_setup" + AdminlessDeletion _ -> "adminless_deletion" + AdminlessReminder _ -> "adminless_reminder" + +mapJobPayload :: (a -> b) -> ArbiterCore.JobRead a -> ArbiterCore.JobRead b +mapJobPayload f job = + ArbiterCore.Job + { ArbiterCore.primaryKey = job.primaryKey, + ArbiterCore.payload = f job.payload, + ArbiterCore.queueName = job.queueName, + ArbiterCore.groupKey = job.groupKey, + ArbiterCore.insertedAt = job.insertedAt, + ArbiterCore.updatedAt = job.updatedAt, + ArbiterCore.attempts = job.attempts, + ArbiterCore.lastError = job.lastError, + ArbiterCore.priority = job.priority, + ArbiterCore.lastAttemptedAt = job.lastAttemptedAt, + ArbiterCore.notVisibleUntil = job.notVisibleUntil, + ArbiterCore.dedupKey = job.dedupKey, + ArbiterCore.maxAttempts = job.maxAttempts, + ArbiterCore.parentId = job.parentId, + ArbiterCore.parentState = job.parentState, + ArbiterCore.suspended = job.suspended, + ArbiterCore.claimedBy = job.claimedBy, + ArbiterCore.archiveFor = job.archiveFor, + ArbiterCore.admission = job.admission + } + +applyExplicitDefaults :: + JobWorkerSettings -> + ArbiterWorker.WorkerConfig m payload -> + ArbiterWorker.WorkerConfig m payload +applyExplicitDefaults settings cfg = + cfg + { -- How often the dispatcher wakes up to look for newly visible jobs. + -- Lower values reduce discovery latency at the cost of more DB traffic. + ArbiterWorkerConfig.pollInterval = settings.jobPollInterval, + -- How long a claimed job stays invisible while a worker processes it. + -- Must exceed the job heartbeat interval so active jobs are not reclaimed. + ArbiterWorkerConfig.visibilityTimeout = settings.jobVisibilityTimeout, + -- How often a running job refreshes its visibility timeout. + -- Keeps long-running jobs from being reclaimed mid-flight. + ArbiterWorkerConfig.jobHeartbeatInterval = settings.jobHeartbeatInterval, + -- How often the worker process updates its own heartbeat and pause state. + -- This drives liveness, re-registration, and paused-state reconciliation. + ArbiterWorkerConfig.workerHeartbeatInterval = settings.jobWorkerHeartbeatInterval, + -- Retry strategy for transient worker failures. + -- Arbiter uses exponential backoff with jitter by default. + ArbiterWorkerConfig.backoffStrategy = + ArbiterWorker.exponentialBackoff + settings.jobBackoffBase + settings.jobBackoffCap, + -- Jitter mode for retry delays. + -- Equal jitter smooths retry spikes without making them too aggressive. + ArbiterWorkerConfig.jitter = settings.jobJitter, + -- How long the worker waits for in-flight jobs during shutdown. + -- If set, the pool exits after this grace period instead of waiting forever. + ArbiterWorkerConfig.gracefulShutdownTimeout = settings.jobGracefulShutdownTimeout, + -- How often the reaper runs. It refreshes groups, sweeps stale workers, + -- and moves exhausted jobs to the DLQ. + ArbiterWorkerConfig.reaperInterval = settings.jobReaperInterval, + -- Maximum time allowed for one reaper pass. + ArbiterWorkerConfig.reaperTimeout = settings.jobReaperTimeout, + -- How old a worker heartbeat may be before it is considered stale. + -- Stale workers are swept from the registry by the reaper. + ArbiterWorkerConfig.workerStaleThreshold = settings.jobWorkerStaleThreshold + } diff --git a/services/background-worker/src/Wire/Effects.hs b/services/background-worker/src/Wire/Effects.hs index 09768fe3e17..0d367f19eed 100644 --- a/services/background-worker/src/Wire/Effects.hs +++ b/services/background-worker/src/Wire/Effects.hs @@ -20,6 +20,7 @@ module Wire.Effects ) where +import Arbiter.Core qualified as ArbiterCore import Bilge qualified import Bilge.Retry import Cassandra (ClientState) @@ -27,6 +28,7 @@ import Control.Monad.Catch import Control.Retry import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS +import Data.Domain (Domain) import Data.Id import Data.Misc import Data.Qualified @@ -35,7 +37,7 @@ import Data.Text qualified as T import Data.Text.Lazy qualified as TL import Galley.Types.Error (InternalError, internalErrorDescription, legalHoldServiceUnavailable) import Hasql.Pool (UsageError) -import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports import Network.HTTP.Client qualified as Http import Network.Wai.Utilities.JSONResponse (JSONResponse (..)) @@ -89,9 +91,14 @@ import Wire.GalleyAPIAccess.Rpc (interpretGalleyAPIAccessToRpc) import Wire.GundeckAPIAccess import Wire.HashPassword (HashPassword) import Wire.HashPassword.Interpreter (runHashPassword) +import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) +import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) +import Wire.MeetingNotifier (MeetingNotifier) +import Wire.MeetingNotifier.NoOpInterpreter (discardMeetingNotifier) +import Wire.MigrationLock (MigrationLockError) import Wire.NotificationSubsystem (NotificationSubsystem) import Wire.NotificationSubsystem.Interpreter import Wire.Options.Galley (GuestLinkTTLSeconds) @@ -123,6 +130,8 @@ import Wire.TeamCollaboratorsSubsystem.Interpreter (interpretTeamCollaboratorsSu import Wire.TeamFeatureStore (TeamFeatureStore) import Wire.TeamFeatureStore.Cassandra (interpretTeamFeatureStoreToCassandra) import Wire.TeamFeatureStore.Error (TeamFeatureStoreError) +import Wire.TeamFeatureStore.Migrating (interpretTeamFeatureStoreToCassandraAndPostgres) +import Wire.TeamFeatureStore.Postgres (interpretTeamFeatureStoreToPostgres) import Wire.TeamJournal (TeamJournal) import Wire.TeamJournal.Aws (interpretTeamJournal) import Wire.TeamStore (TeamStore) @@ -184,6 +193,7 @@ makeVerifiedRequestFreshManagerIO logger fpr url reqBuilder = do type BackgroundWorkerEffects = '[ ConversationSubsystem, + MeetingNotifier, TeamCollaboratorsSubsystem, Input AllTeamFeatures, FeaturesConfigSubsystem, @@ -209,6 +219,8 @@ type BackgroundWorkerEffects = Now, TeamJournal, LegalHoldStore, + JobSubsystem, + Input RequestId, TeamCollaboratorsStore, TeamStore, ConversationStore, @@ -222,13 +234,13 @@ type BackgroundWorkerEffects = Input (Maybe GuestLinkTTLSeconds), Input (Maybe GroupInfoCheckEnabled), Input IntraListing, - Input (Either HttpsUrl (Map Text HttpsUrl)), + Input (Either HttpsUrl (Map Domain HttpsUrl)), Input ExposeInvitationURLsAllowlist, Input LegalHoldEnv, Input ClientState, Input (FeatureDefaults LegalholdConfig), Input (Local ()), - Input Hasql.Pool, + Input HasqlPoolExt.Pool, P.TinyLog, Error RateLimitExceeded, Error UnreachableBackendsLegacy, @@ -241,6 +253,7 @@ type BackgroundWorkerEffects = Error (Tagged CodeStoreNotFound ()), Error TeamFeatureStoreError, Error TeamCollaboratorsError, + Error MigrationLockError, Error UnreachableBackends, Error InternalError, Error MigrationError, @@ -289,6 +302,7 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . mapError @MigrationError (T.pack . show) . mapError @InternalError (TL.toStrict . internalErrorDescription) . mapError @UnreachableBackends (T.pack . show) + . mapError @MigrationLockError (const ("Migration lock error" :: Text)) . mapError @TeamCollaboratorsError (const ("Team collaborators error" :: Text)) . mapError @TeamFeatureStoreError (const ("Team feature store error" :: Text)) . mapError @(Tagged 'CodeStoreNotFound ()) (const ("Code store not found" :: Text)) @@ -301,13 +315,13 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . mapError @UnreachableBackendsLegacy (const ("Unreachable backends legacy" :: Text)) . mapError @RateLimitExceeded (const ("Rate limit exceeded" :: Text)) . interpretTinyLog - . runInputConst @Hasql.Pool env.hasqlPool + . runInputConst @HasqlPoolExt.Pool env.hasqlPool . runInputConst @(Local ()) (toLocalUnsafe env.federationDomain ()) . runInputConst @(FeatureDefaults LegalholdConfig) (env.conversationSubsystemConfig.legalholdDefaults) . runInputConst @ClientState env.cassandraGalley . runInputConst @LegalHoldEnv legalHoldEnv . runInputConst @ExposeInvitationURLsAllowlist (ExposeInvitationURLsAllowlist $ fromMaybe [] env.exposeInvitationURLsTeamAllowlist) - . runInputConst @(Either HttpsUrl (Map Text HttpsUrl)) env.convCodeURI + . runInputConst @(Either HttpsUrl (Map Domain HttpsUrl)) env.convCodeURI . runInputConst @IntraListing (IntraListing env.intraListing) . runInputConst @(Maybe GroupInfoCheckEnabled) (GroupInfoCheckEnabled <$> env.checkGroupInfo) . runInputConst @(Maybe GuestLinkTTLSeconds) env.guestLinkTTLSeconds @@ -316,11 +330,13 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . interpretProposalStoreToCassandra . interpretServiceStoreToCassandra env.cassandraBrig . interpretUserGroupStoreToPostgres - . interpretTeamFeatureStoreToCassandra + . interpretTeamFeatureStore . interpretUserClientIndexStoreToCassandra env.cassandraGalley . interpretConversationStoreByMigration env.postgresMigration.conversation env.cassandraGalley . interpretTeamStoreToCassandra . interpretTeamCollaboratorsStoreToPostgres + . runInputConst @RequestId requestId + . interpretJobSubsystem jobSubsystemConfig . interpretLegalHoldStoreToCassandra (env.conversationSubsystemConfig.legalholdDefaults) . interpretTeamJournal Nothing . nowToIO @@ -352,13 +368,18 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = . runFeaturesConfigSubsystem . runInputSem getAllTeamFeaturesForServer . interpretTeamCollaboratorsSubsystem + . discardMeetingNotifier . interpretConversationSubsystem where - convCodesStoreInterpreter = - case env.postgresMigration.conversationCodes of - CassandraStorage -> interpretCodeStoreToCassandra - MigrationToPostgresql -> interpretCodeStoreToCassandraAndPostgres - PostgresqlStorage -> interpretCodeStoreToPostgres + interpretTeamFeatureStore = case env.postgresMigration.teamFeatures of + CassandraStorage -> interpretTeamFeatureStoreToCassandra + MigrationToPostgresql -> interpretTeamFeatureStoreToCassandraAndPostgres + PostgresqlStorage -> interpretTeamFeatureStoreToPostgres + + convCodesStoreInterpreter = case env.postgresMigration.conversationCodes of + CassandraStorage -> interpretCodeStoreToCassandra + MigrationToPostgresql -> interpretCodeStoreToCassandraAndPostgres + PostgresqlStorage -> interpretCodeStoreToPostgres legalHoldEnv = let makeReq fpr url rb = makeVerifiedRequestIO env.logger extEnv fpr url rb makeReqFresh fpr url rb = makeVerifiedRequestFreshManagerIO env.logger fpr url rb @@ -371,6 +392,10 @@ runBackgroundWorkerEffects env extEnv requestId mJobId = http2Manager = env.http2Manager, requestId = requestId } + jobSubsystemConfig = + JobSubsystemConfig + { jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + } backendQueueEnv = BackendNotificationQueueAccess.Env { channelMVar = env.amqpBackendNotificationsChannel, diff --git a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs index 1fd4db93ff9..bd3300260e8 100644 --- a/services/background-worker/src/Wire/MeetingsCleanupWorker.hs +++ b/services/background-worker/src/Wire/MeetingsCleanupWorker.hs @@ -16,8 +16,8 @@ -- with this program. If not, see . module Wire.MeetingsCleanupWorker - ( startWorker, - CleanupConfig (..), + ( CleanupConfig (..), + runCleanupOldMeetings, ) where @@ -28,11 +28,10 @@ import Data.Time.Clock import Imports import Polysemy.Error (runError) import Prometheus (incCounter) -import System.Cron (Job (..), forkJob) import System.Logger qualified as Log -import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..), runAppT) -import Wire.BackgroundWorker.Options (MeetingsCleanupConfig (..)) -import Wire.BackgroundWorker.Util (CleanupAction) +import Wire.API.Error.Galley (MeetingError) +import Wire.API.Meeting (defaultLegacyTimeZone) +import Wire.BackgroundWorker.Env (AppT, Env (..), MeetingsCleanupMetrics (..)) import Wire.Effects import Wire.ExternalAccess.External import Wire.MeetingsStore.Postgres (interpretMeetingsStoreToPostgres) @@ -45,37 +44,6 @@ data CleanupConfig = CleanupConfig } deriving (Show, Eq) --- | Start the meetings cleanup worker thread --- --- This worker runs periodically to clean up old meetings based on the configuration. -startWorker :: - MeetingsCleanupConfig -> - AppT IO CleanupAction -startWorker config = do - env <- ask - Log.info env.logger $ - Log.msg (Log.val "Starting meetings cleanup worker") - . Log.field "schedule" (show config.schedule) - . Log.field "clean_older_than_hours" config.cleanOlderThanHours - - void . liftIO $ do - forkJob $ - Job config.schedule $ - runAppT env $ do - Log.info env.logger $ Log.msg (Log.val "Starting scheduled meetings cleanup") - runCleanupOldMeetings (configFromOptions config) - liftIO $ incCounter env.meetingsCleanupMetrics.runsCounter - - pure $ pure () - --- | Convert MeetingsCleanupConfig to CleanupConfig -configFromOptions :: MeetingsCleanupConfig -> CleanupConfig -configFromOptions cfg = - CleanupConfig - { retentionHours = cfg.cleanOlderThanHours, - batchSize = cfg.batchSize - } - -- | Main cleanup function that orchestrates the cleanup process runCleanupOldMeetings :: CleanupConfig -> AppT IO () runCleanupOldMeetings config = do @@ -95,6 +63,7 @@ runCleanupOldMeetings config = do Log.info env.logger $ Log.msg (Log.val "Completed cleanup of old meetings") . Log.field "total_deleted" totalDeleted + liftIO $ incCounter env.meetingsCleanupMetrics.runsCounter cleanupLoop :: Env -> UTCTime -> NominalDiffTime -> Int -> Int64 -> AppT IO Int64 cleanupLoop env cutoffTime validityPeriod batchSize totalSoFar = do @@ -134,7 +103,10 @@ runMeetingsCleanup env cutoffTime validityPeriod batchSize = do . runBackgroundWorkerEffects env extEnv (RequestId "meetings-cleanup") Nothing . interpretMeetingsStoreToPostgres . runError @MeetingError - . interpretMeetingsSubsystem validityPeriod + -- Cleanup performs no meeting updates, so the past-edit period is + -- unused; pass 0 to keep the (inert) limit as tight as possible. + . interpretMeetingsSubsystem + MeetingSystemConfig {legacyTimeZone = defaultLegacyTimeZone, validityPeriod, pastEditPeriod = 0} $ Wire.MeetingsSubsystem.cleanupOldMeetings cutoffTime batchSize data WorkerException = WorkerException Text diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index 4b32861ba3e..7222120d93a 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -32,6 +32,7 @@ import Data.Domain import Data.Id import Data.Misc import Data.Range +import Data.Secret (secretText) import Data.Sequence qualified as Seq import Data.Text qualified as Text import Data.Text.Encoding qualified as Text @@ -384,6 +385,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = @@ -447,6 +449,7 @@ spec = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index ddecd07b9e9..5d89532bfec 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -24,6 +24,7 @@ import Data.Domain (Domain (Domain)) import Data.Misc import Data.Proxy import Data.Range +import Data.Secret (secretText) import Imports import Network.HTTP.Client hiding (Proxy) import System.Logger.Class qualified as Logger @@ -83,6 +84,7 @@ testEnv = do guestLinkTTLSeconds = Nothing passwordHashingOptions = PasswordHashingScrypt checkGroupInfo = Nothing + arbiterConnStr = secretText "" convCodeURI = Left (fromRight (error "Failed to parse test HttpsUrl") $ httpsUrlFromText "https://localhost") featureFlags = def conversationSubsystemConfig = diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 2f7ab0b0789..89b1d88f7a7 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -98,25 +98,18 @@ library Brig.AWS Brig.AWS.SesNotification Brig.AWS.Types - Brig.Budget Brig.Calling Brig.Calling.API Brig.Calling.Internal Brig.CanonicalInterpreter Brig.Data.Activation Brig.Data.Connection - Brig.Data.MLS.KeyPackage Brig.Data.Nonce Brig.Data.Types Brig.Data.User Brig.DeleteQueue.Interpreter Brig.Effects.ConnectionStore Brig.Effects.ConnectionStore.Cassandra - Brig.Effects.JwtTools - Brig.Effects.PublicKeyBundle - Brig.Effects.SFT - Brig.Effects.UserPendingActivationStore - Brig.Effects.UserPendingActivationStore.Cassandra Brig.Index.Eval Brig.Index.Options Brig.Index.Types @@ -189,6 +182,7 @@ library Brig.Schema.V90_DomainRegistrationTeamIndex Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl Brig.Schema.V92_AddUserType + Brig.Schema.V93_AddScimPendingUserEmail Brig.Team.API Brig.Team.Template Brig.Template @@ -245,7 +239,7 @@ library , fsnotify >=0.4 , galley-types >=0.75.3 , hashable >=1.2 - , hasql-pool + , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , HsOpenSSL >=0.10 @@ -356,7 +350,6 @@ executable brig-integration API.Team API.Team.Util API.TeamUserSearch - API.Template API.User API.User.Account API.User.Auth diff --git a/services/brig/brig.integration.yaml b/services/brig/brig.integration.yaml index 05ec68a0213..8be11f028bd 100644 --- a/services/brig/brig.integration.yaml +++ b/services/brig/brig.integration.yaml @@ -29,7 +29,6 @@ postgresql: postgresqlPool: size: 20 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m rabbitmq: diff --git a/services/brig/default.nix b/services/brig/default.nix index 4ccc141c029..e431f9c93db 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -47,7 +47,7 @@ , fsnotify , galley-types , hashable -, hasql-pool +, hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , hscim @@ -192,7 +192,7 @@ mkDerivation { fsnotify galley-types hashable - hasql-pool + hasql-resource-pool hs-opentelemetry-instrumentation-wai hs-opentelemetry-sdk HsOpenSSL diff --git a/services/brig/docs/swagger-v16.json b/services/brig/docs/swagger-v16.json index b79c0837518..045397da930 100644 --- a/services/brig/docs/swagger-v16.json +++ b/services/brig/docs/swagger-v16.json @@ -1 +1 @@ -{"components":{"schemas":{"ASCII":{"example":"aGVsbG8","type":"string"},"AcceptTeamInvitation_Nzg5NzI3MjA2":{"description":"Accept an invitation to join a team on Wire.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"password":{"description":"The user account password.","maxLength":1024,"minLength":6,"type":"string"}},"required":["code","password"],"type":"object"},"AccessRoleLegacy_LTYwOTAxMDI1":{"deprecated":true,"description":"Deprecated, please use access_role_v2","enum":["private","team","activated","non_activated"],"type":"string"},"AccessRole_Mzk3MDYzMzcw":{"description":"Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.","enum":["team_member","non_team_member","guest","service"],"type":"string"},"AccessTokenType_LTgyOTY0NDE5":{"enum":["DPoP"],"type":"string"},"AccessToken_ODIyMTczMjMw":{"properties":{"access_token":{"description":"The opaque access token string","type":"string"},"expires_in":{"description":"The number of seconds this token is valid","type":"integer"},"token_type":{"$ref":"#/components/schemas/TokenType_NTkyMzk4MjIz"},"user":{"$ref":"#/components/schemas/UUID"}},"required":["user","access_token","token_type","expires_in"],"type":"object"},"Access_NjkyMzE5ODc0":{"description":"How users can join conversations","enum":["private","invite","link","code"],"type":"string"},"AccountStatus_NzkzNDU1ODU5":{"enum":["active","suspended","deleted","ephemeral","pending-invitation"],"type":"string"},"Action":{"enum":["add_conversation_member","remove_conversation_member","modify_conversation_name","modify_conversation_message_timer","modify_conversation_receipt_mode","modify_conversation_access","modify_other_conversation_member","leave_conversation","delete_conversation","modify_add_permission"],"type":"string"},"Activate_MzUzNzIxODUw":{"description":"Data for an activation request.","properties":{"code":{"$ref":"#/components/schemas/ASCII"},"dryrun":{"description":"At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.","type":"boolean"},"email":{"$ref":"#/components/schemas/Email"},"key":{"$ref":"#/components/schemas/ASCII"}},"required":["code","dryrun"],"type":"object"},"ActivationResponse_LTIyOTY5NDE3":{"description":"Response body of a successful activation request","properties":{"email":{"$ref":"#/components/schemas/Email"},"first":{"description":"Whether this is the first successful activation (i.e. account activation).","type":"boolean"},"sso_id":{"$ref":"#/components/schemas/UserSSOId"}},"type":"object"},"AddBotResponse_ODA5MzA2NTA1":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"assets":{"items":{"$ref":"#/components/schemas/Asset_LTIyMjc1NDEz"},"type":"array"},"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"event":{"$ref":"#/components/schemas/Event_LTMwMTMyODM5"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"}},"required":["id","client","name","accent_id","assets","event"],"type":"object"},"AddBot_NjI0ODkyODk3":{"properties":{"locale":{"$ref":"#/components/schemas/Locale"},"provider":{"$ref":"#/components/schemas/UUID"},"service":{"$ref":"#/components/schemas/UUID"}},"required":["provider","service"],"type":"object"},"AddPermissionUpdate_LTU3MzEwOTY4":{"description":"The action of changing the permission to add members to a channel","properties":{"add_permission":{"$ref":"#/components/schemas/AddPermission_LTE1MzgzNzE3"}},"required":["add_permission"],"type":"object"},"AddPermission_LTE1MzgzNzE3":{"enum":["admins","everyone"],"type":"string"},"AllowedGlobalOperationsConfig_MzAwOTU1MDkx":{"properties":{"mlsConversationReset":{"type":"boolean"}},"required":["mlsConversationReset"],"type":"object"},"Alpha_LTE4NDUxNDQ4":{"description":"ISO 4217 alphabetic codes. This is only stored by the backend, not processed. It can be removed once billing supports currency changes after team creation.","enum":["AED","AFN","ALL","AMD","ANG","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BOV","BRL","BSD","BTN","BWP","BYN","BZD","CAD","CDF","CHE","CHF","CHW","CLF","CLP","CNY","COP","COU","CRC","CUC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","EUR","FJD","FKP","GBP","GEL","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HRK","HTG","HUF","IDR","ILS","INR","IQD","IRR","ISK","JMD","JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRO","MUR","MVR","MWK","MXN","MXV","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLL","SOS","SRD","SSP","STD","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","USN","UYI","UYU","UZS","VEF","VND","VUV","WST","XAF","XAG","XAU","XBA","XBB","XBC","XBD","XCD","XDR","XOF","XPD","XPF","XPT","XSU","XTS","XUA","XXX","YER","ZAR","ZMW","ZWL"],"example":"EUR","type":"string"},"AppInfo_MjgwNTkwOTUz":{"properties":{"category":{"description":"Category name (if uncertain, pick \"other\")","type":"string"},"description":{"maxLength":300,"minLength":0,"type":"string"}},"required":["category","description"],"type":"object"},"AppLockConfigB_Covered_Identity_NDIxOTc2Njkz":{"properties":{"enforceAppLock":{"type":"boolean"},"inactivityTimeoutSecs":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"}},"required":["enforceAppLock","inactivityTimeoutSecs"],"type":"object"},"ApproveLegalHoldForUserRequest_NjEyNzYyMTIx":{"properties":{"password":{"maxLength":1024,"minLength":6,"type":"string"}},"type":"object"},"AssetKey":{"description":"S3 asset key for an icon image with retention information.","example":"3-1-47de4580-ae51-4650-acbb-d10c028cb0ac","type":"string"},"AssetSize_OTAwMDA3ODY2":{"enum":["preview","complete"],"type":"string"},"AssetSource":{},"Asset_LTIyMjc1NDEz":{"properties":{"key":{"$ref":"#/components/schemas/AssetKey"},"size":{"$ref":"#/components/schemas/AssetSize_OTAwMDA3ODY2"},"type":{"$ref":"#/components/schemas/MTYxOTI3NjM3"}},"required":["key","type"],"type":"object"},"Asset_Qualified_AssetKey_MzU1MjMxNTA5":{"properties":{"domain":{"$ref":"#/components/schemas/Domain"},"expires":{"$ref":"#/components/schemas/UTCTimeMillis"},"key":{"$ref":"#/components/schemas/AssetKey"},"token":{"$ref":"#/components/schemas/ASCII"}},"required":["key","domain"],"type":"object"},"AuthSFTServer_LTY5MzcyOTE0":{"description":"Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers","properties":{"credential":{"$ref":"#/components/schemas/ASCII"},"urls":{"description":"Array containing exactly one SFT server address of the form 'https://:'","items":{"$ref":"#/components/schemas/HttpsUrl"},"type":"array"},"username":{"$ref":"#/components/schemas/SFTUsername"}},"required":["urls"],"type":"object"},"AuthnRequest":{"properties":{"iD":{"$ref":"#/components/schemas/Id_AuthnRequest"},"issueInstant":{"$ref":"#/components/schemas/Time"},"issuer":{"$ref":"#/components/schemas/URI"},"nameIDPolicy":{"$ref":"#/components/schemas/NameIdPolicy"}},"required":["iD","issueInstant","issuer"],"type":"object"},"Base64ByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"Base64URLByteString":{"example":"ZXhhbXBsZQo=","type":"string"},"BaseProtocolTag_LTM0MDE1NTEx":{"enum":["proteus","mls"],"type":"string"},"BindingNewTeamUser_LTY0MDQxMDEw":{"properties":{"currency":{"$ref":"#/components/schemas/Alpha_LTE4NDUxNDQ4"},"icon":{"$ref":"#/components/schemas/Icon"},"icon_key":{"description":"The decryption key for the team icon S3 asset","maxLength":256,"minLength":1,"type":"string"},"name":{"description":"team name","maxLength":256,"minLength":1,"type":"string"}},"required":["name","icon"],"type":"object"},"BotConvView_LTYzMjIzMjQz":{"properties":{"id":{"$ref":"#/components/schemas/UUID"},"members":{"items":{"$ref":"#/components/schemas/OtherMember_LTgzNzE2MTk4"},"type":"array"},"name":{"type":"string"}},"required":["id","members"],"type":"object"},"BotUserView_LTE2MTkwMTcw":{"properties":{"accent_id":{"format":"int32","maximum":2147483647,"minimum":-2147483648,"type":"integer"},"handle":{"$ref":"#/components/schemas/Handle"},"id":{"$ref":"#/components/schemas/UUID"},"name":{"maxLength":128,"minLength":1,"type":"string"},"team":{"$ref":"#/components/schemas/UUID"}},"required":["id","name","accent_id"],"type":"object"},"CellsBackend_LTE1Nzg3NzQ2":{"properties":{"url":{"$ref":"#/components/schemas/HttpsUrl"}},"required":["url"],"type":"object"},"CellsCollaboraStatus_MTgzNTQyNzUz":{"properties":{"enabled":{"type":"boolean"}},"required":["enabled"],"type":"object"},"CellsCollabora_LTMzNDA5MDIz":{"properties":{"edition":{"$ref":"#/components/schemas/CollaboraEdition_LTg2NDA1NDQ4"}},"required":["edition"],"type":"object"},"CellsConfigB_Covered_Identity_LTE1NzkwOTcz":{"example":{"channels":{"default":"enabled","enabled":true},"collabora":{"enabled":false},"groups":{"default":"enabled","enabled":true},"metadata":{"namespaces":{"usermetaTags":{"allowFreeValues":true,"defaultValues":[]}}},"one2one":{"default":"enabled","enabled":true},"publicLinks":{"enableFiles":true,"enableFolders":true,"enforceExpirationDefault":0,"enforceExpirationMax":0,"enforcePassword":false},"storage":{"perFileQuotaBytes":"100000000","recycle":{"allowSkip":false,"autoPurgeDays":30,"disable":false}},"users":{"externals":true,"guests":false}},"properties":{"channels":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"collabora":{"$ref":"#/components/schemas/CellsCollaboraStatus_MTgzNTQyNzUz"},"groups":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"metadata":{"$ref":"#/components/schemas/CellsMetadata_LTY1OTM5MTM0"},"one2one":{"$ref":"#/components/schemas/CellsProperty_NzcxMDIzMzk0"},"publicLinks":{"$ref":"#/components/schemas/CellsPublicLinks_MjgxMzQ3Mzk4"},"storage":{"$ref":"#/components/schemas/CellsConfigStorage_LTM0NDMwODM4"},"users":{"$ref":"#/components/schemas/CellsUsers_LTQ4NTEyODA1"}},"required":["channels","groups","one2one","users","collabora","publicLinks","storage","metadata"],"type":"object"},"CellsConfigStorage_LTM0NDMwODM4":{"properties":{"perFileQuotaBytes":{"type":"string"},"recycle":{"$ref":"#/components/schemas/CellsRecycle_LTQxMTg3NTkx"}},"required":["perFileQuotaBytes","recycle"],"type":"object"},"CellsInternalConfigB_Covered_Identity_LTUzMDkwOTAz":{"properties":{"backend":{"$ref":"#/components/schemas/CellsBackend_LTE1Nzg3NzQ2"},"collabora":{"$ref":"#/components/schemas/CellsCollabora_LTMzNDA5MDIz"},"storage":{"$ref":"#/components/schemas/CellsStorage_LTY2Mzc5NzY1"}},"required":["backend","collabora","storage"],"type":"object"},"CellsMetadata_LTY1OTM5MTM0":{"properties":{"namespaces":{"$ref":"#/components/schemas/CellsNamespaces_MzUxMjEzOTQw"}},"required":["namespaces"],"type":"object"},"CellsNamespaces_MzUxMjEzOTQw":{"properties":{"usermetaTags":{"$ref":"#/components/schemas/CellsUserMetaTags_LTc4Njk4NTY0"}},"required":["usermetaTags"],"type":"object"},"CellsPropertyStatus_MTQ5NjE2MzQ4":{"enum":["enabled","disabled","enforced"],"type":"string"},"CellsProperty_NzcxMDIzMzk0":{"properties":{"default":{"$ref":"#/components/schemas/CellsPropertyStatus_MTQ5NjE2MzQ4"},"enabled":{"type":"boolean"}},"required":["enabled","default"],"type":"object"},"CellsPublicLinks_MjgxMzQ3Mzk4":{"properties":{"enableFiles":{"type":"boolean"},"enableFolders":{"type":"boolean"},"enforceExpirationDefault":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforceExpirationMax":{"format":"int64","maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"enforcePassword":{"type":"boolean"}},"required":["enableFiles","enableFolders","enforcePassword","enforceExpirationMax","enforceExpirationDefault"],"type":"object"},"CellsRecycle_LTQxMTg3NTkx":{"properties":{"allowSkip":{"type":"boolean"},"autoPurgeDays":{"maximum":9223372036854775807,"minimum":-9223372036854775808,"type":"integer"},"disable":{"type":"boolean"}},"required":["autoPurgeDays","disable","allowSkip"],"type":"object"},"CellsState_LTg4MDEwNDA5":{"enum":["disabled","pending","ready"],"type":"string"},"CellsStorage_LTY2Mzc5NzY1":{"properties":{"perUserQuotaBytes":{"type":"string"}},"required":["perUserQuotaBytes"],"type":"object"},"CellsUserMetaTags_LTc4Njk4NTY0":{"properties":{"allowFreeValues":{"type":"boolean"},"defaultValues":{"items":{"type":"string"},"type":"array"}},"required":["defaultValues","allowFreeValues"],"type":"object"},"CellsUsers_LTQ4NTEyODA1":{"properties":{"externals":{"type":"boolean"},"guests":{"type":"boolean"}},"required":["externals","guests"],"type":"object"},"ChallengeToken_Mzk3NTcwOTM3":{"properties":{"challenge_token":{"$ref":"#/components/schemas/Token"}},"required":["challenge_token"],"type":"object"},"ChannelPermissions_Mzc1MTM3NTg2":{"enum":["team-members","everyone","admins"],"type":"string"},"ChannelsConfigB_Covered_Identity_ODk2MTk3NDQ4":{"properties":{"allowed_to_create_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"},"allowed_to_open_channels":{"$ref":"#/components/schemas/ChannelPermissions_Mzc1MTM3NTg2"}},"required":["allowed_to_create_channels","allowed_to_open_channels"],"type":"object"},"CheckHandles_LTc0OTkxMzAx":{"properties":{"handles":{"items":{"type":"string"},"maxItems":50,"minItems":1,"type":"array"},"return":{"maximum":10,"minimum":1,"type":"integer"}},"required":["handles","return"],"type":"object"},"CheckUserGroupName_LTg0ODU1OTk1":{"properties":{"name":{"maxLength":4000,"minLength":1,"type":"string"}},"required":["name"],"type":"object"},"CipherSuiteTag":{"description":"The cipher suite of the corresponding MLS group","maximum":65535,"minimum":0,"type":"integer"},"ClassifiedDomainsConfig_LTg4MDcwMDg2":{"properties":{"domains":{"items":{"$ref":"#/components/schemas/Domain"},"type":"array"}},"required":["domains"],"type":"object"},"ClientCapabilityList":{"items":{"$ref":"#/components/schemas/ClientCapability_MTY2NDAzMjM3"},"type":"array"},"ClientCapability_MTY2NDAzMjM3":{"enum":["legalhold-implicit-consent","consumable-notifications"],"type":"string"},"ClientClass_NjE3MDgwNzcx":{"enum":["phone","tablet","desktop","legalhold"],"type":"string"},"ClientIdentity_MjAxMjI3NTUw":{"properties":{"client_id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"domain":{"$ref":"#/components/schemas/Domain"},"user_id":{"$ref":"#/components/schemas/UUID"}},"required":["domain","user_id","client_id"],"type":"object"},"ClientMismatch_ODUyODM0MDQ0":{"properties":{"deleted":{"$ref":"#/components/schemas/UserClients"},"missing":{"$ref":"#/components/schemas/UserClients"},"redundant":{"$ref":"#/components/schemas/UserClients"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"}},"required":["time","missing","redundant","deleted"],"type":"object"},"ClientPrekey_LTcyODUzMTcw":{"properties":{"client":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"prekey":{"$ref":"#/components/schemas/UncheckedPrekeyBundle_LTU1MzQzOTgy"}},"required":["client","prekey"],"type":"object"},"ClientType_MjQ0OTQwMzcw":{"enum":["temporary","permanent","legalhold"],"type":"string"},"Client_MTM1OTcwOTQ1":{"properties":{"capabilities":{"$ref":"#/components/schemas/ClientCapabilityList"},"class":{"$ref":"#/components/schemas/ClientClass_NjE3MDgwNzcx"},"cookie":{"type":"string"},"id":{"description":"A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros","type":"string"},"label":{"type":"string"},"last_active":{"$ref":"#/components/schemas/UTCTime"},"mls_public_keys":{"$ref":"#/components/schemas/MLSPublicKeys"},"model":{"type":"string"},"time":{"$ref":"#/components/schemas/UTCTimeMillis"},"type":{"$ref":"#/components/schemas/ClientType_MjQ0OTQwMzcw"}},"required":["id","type","time"],"type":"object"},"CodeChallengeMethod_NTIxNzk0NDgw":{"description":"The method used to encode the code challenge. Only `S256` is supported.","enum":["S256"],"type":"string"},"CollaboraEdition_LTg2NDA1NDQ4":{"enum":["NO","CODE","COOL"],"type":"string"},"CollaboratorPermission_NDg5NTg2ODgy":{"description":"

Permission granted to a team collaborator.

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

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

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

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

Use exactly two parts in this order:

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

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

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

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

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

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

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

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

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

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

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

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

Use exactly two parts in this order:

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

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

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

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

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

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

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

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

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

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

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

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

Use exactly two parts in this order:

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

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

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

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

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

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

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

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

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

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

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

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

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

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

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

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

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

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

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

See also: GET /notifications

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

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

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

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

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

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

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

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

Permission granted to a team collaborator.

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

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

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

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

Use exactly two parts in this order:

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

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

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

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

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

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

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

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

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

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

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

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

Use exactly two parts in this order:

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

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

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

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

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

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

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

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

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

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

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

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

Use exactly two parts in this order:

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

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

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

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

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

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

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

Example body (boundary=frontier):

Content-Type: multipart/mixed; boundary=frontier

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

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

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

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

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

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

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

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

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

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

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

See also: GET /notifications

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

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

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

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

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

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

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

","operationId":"get_RequireExternalEmailVerificationConfig","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LockableFtur_RqiExnmVfCg_NjUyMzgzNzY5"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member","operation-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Get config for validateSAMLemails"}},"/teams/{tid}/get-members-by-ids-using-post":{"post":{"description":" [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.","operationId":"get-team-members-by-ids","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserIdList_MzA1MTI1Njgx"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberList_Optional_LTM1ODE2MzM0"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"too-many-uids","message":"Can only process 2000 user ids per request."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["too-many-uids"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body` or `maxResults`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get team members by user id list"}},"/teams/{tid}/invitations":{"get":{"description":" [internal route ID: \"get-team-invitations\"]\n\n","operationId":"get-team-invitations","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Invitation id to start from (ascending).","in":"query","name":"start","required":false,"schema":{"format":"uuid","type":"string"}},{"description":"Number of results to return (default 100, max 500).","in":"query","name":"size","required":false,"schema":{"format":"int32","maximum":500,"minimum":1,"type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationList_ODk4NTQxODc3"}}},"description":"List of sent invitations"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"List the sent team invitations"},"post":{"description":" [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.","operationId":"send-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/InvitationRequest_LTcyMDIzNDc0"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation was created and sent.","headers":{"Location":{"schema":{"format":"url","type":"string"}}}},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code","invalid-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nInvalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions","too-many-team-invitations","blacklisted-email","no-identity","no-email"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified email (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)"}},"summary":"Create and send a new team invitation."}},"/teams/{tid}/invitations/{iid}":{"delete":{"description":" [internal route ID: \"delete-team-invitation\"]\n\n","operationId":"delete-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"description":"Invitation deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"Delete a pending team invitation by ID."},"get":{"description":" [internal route ID: \"get-team-invitation\"]\n\n","operationId":"get-team-invitation","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"iid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/Invitation_NTkzMDYwODc1"}}},"description":"Invitation"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"Notification not found."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `iid` or Notification not found. (label: `not-found`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"duplicate-entry","message":"Entry already exists"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["duplicate-entry"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Entry already exists (label: `duplicate-entry`)"}},"summary":"Get a pending team invitation by ID."}},"/teams/{tid}/legalhold/consent":{"post":{"description":" [internal route ID: \"consent-to-legal-hold\"]\n\n","operationId":"consent-to-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Grant consent successful"},"204":{"description":"Consent already granted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"invalid-op","message":"Invalid operation"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["invalid-op","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Consent to legal hold"}},"/teams/{tid}/legalhold/settings":{"delete":{"description":" [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)","operationId":"delete-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RemoveLegalHoldSettingsRequest_NzMzMzMyMDAz"}}},"required":true},"responses":{"204":{"description":"Legal hold service settings deleted"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-disable-unimplemented","message":"legal hold cannot be disabled for whitelisted teams"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-disable-unimplemented","legalhold-not-enabled","invalid-op","action-denied","no-team-member","operation-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Delete legal hold service settings"},"get":{"description":" [internal route ID: \"get-legal-hold-settings\"]\n\n","operationId":"get-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Get legal hold service settings"},"post":{"description":" [internal route ID: \"create-legal-hold-settings\"]\n\n","operationId":"create-legal-hold-settings","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewLegalHoldService_Mzg0ODQ5NDU1"}}},"required":true},"responses":{"201":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ViewLegalHoldService_LTE3MzQzNDkw"}}},"description":"Legal hold service settings created"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-status-bad","message":"legal hold service: invalid response"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-status-bad","legalhold-invalid-key"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Create legal hold service settings"}},"/teams/{tid}/legalhold/{uid}":{"delete":{"description":" [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)","operationId":"disable-legal-hold-for-user","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/DisableLegalHoldForUserRequest_LTYyMDYxOTEy"}}},"required":true},"responses":{"200":{"description":"Disable legal hold successful"},"204":{"description":"Legal hold was not enabled"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","action-denied","code-authentication-required","code-authentication-failed","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Disable legal hold for user"},"get":{"description":" [internal route ID: \"get-legal-hold\"]\n\n","operationId":"get-legal-hold","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserLegalHoldStatusResponse_LTQ1MzUxMTE3"}}},"description":""},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}},"summary":"Get legal hold status"},"post":{"description":" [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)","operationId":"request-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"201":{"description":"Request device successful"},"204":{"description":"Request device already pending"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered","legalhold-status-bad"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","operation-denied","no-team-member","action-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"mls-legal-hold-not-allowed","message":"A user who is under legal-hold may not participate in MLS conversations"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["mls-legal-hold-not-allowed","legalhold-no-consent","legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"A user who is under legal-hold may not participate in MLS conversations (label: `mls-legal-hold-not-allowed`)\n\nuser has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-illegal-op","message":"internal server error: inconsistent change of user's legalhold state"},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-illegal-op","legalhold-internal"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)"}},"summary":"Request legal hold device"}},"/teams/{tid}/legalhold/{uid}/approve":{"put":{"description":" [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)","operationId":"approve-legal-hold-device","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ApproveLegalHoldForUserRequest_NjEyNzYyMTIx"}}},"required":true},"responses":{"200":{"description":"Legal hold approved"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"legalhold-not-registered","message":"legal hold service has not been registered for this team"},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["legalhold-not-registered"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"legalhold-not-enabled","message":"legal hold is not enabled for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["legalhold-not-enabled","no-team-member","action-denied","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"legalhold-no-device-allocated","message":"no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow."},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["legalhold-no-device-allocated"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nno legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)"},"409":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":409,"label":"legalhold-already-enabled","message":"legal hold is already enabled for this user"},"properties":{"code":{"enum":[409],"type":"integer"},"label":{"enum":["legalhold-already-enabled"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold is already enabled for this user (label: `legalhold-already-enabled`)"},"412":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":412,"label":"legalhold-not-pending","message":"legal hold cannot be approved without being in a pending state"},"properties":{"code":{"enum":[412],"type":"integer"},"label":{"enum":["legalhold-not-pending"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"},"500":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":500,"label":"legalhold-internal","message":"legal hold service: could not block connections when resolving policy conflicts."},"properties":{"code":{"enum":[500],"type":"integer"},"label":{"enum":["legalhold-internal","legalhold-illegal-op"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)"}},"summary":"Approve legal hold device"}},"/teams/{tid}/members":{"get":{"description":" [internal route ID: \"get-team-members\"]\n\n","operationId":"get-team-members","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Maximum results to be returned","in":"query","name":"maxResults","required":false,"schema":{"format":"int32","maximum":2000,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMembersPage_NzYwNDIxODgx"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"}},"summary":"Get team members"},"put":{"description":" [internal route ID: \"update-team-member\"]\n\n","operationId":"update-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewTeamMember_Required_LTg2NjU5OTI2"}}},"required":true},"responses":{"200":{"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","too-many-team-admins","invalid-permissions","access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member","no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)"}},"summary":"Update an existing team member"}},"/teams/{tid}/members/csv":{"get":{"description":" [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.","operationId":"get-team-members-csv","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"text/csv":{}},"description":"CSV of team members"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"access-denied","message":"You do not have permission to access this resource"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["access-denied"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"You do not have permission to access this resource (label: `access-denied`)"}},"summary":"Get all members of the team as a CSV file"}},"/teams/{tid}/members/{uid}":{"delete":{"description":" [internal route ID: \"delete-team-member\"]\n\n","operationId":"delete-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMemberDeleteData_LTg2OTEyOTI4"}}},"required":true},"responses":{"200":{"description":""},"202":{"description":"Team member scheduled for deletion"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member","access-denied","code-authentication-required","code-authentication-failed"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)"},"429":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":429,"label":"too-many-requests","message":"Please try again later."},"properties":{"code":{"enum":[429],"type":"integer"},"label":{"enum":["too-many-requests"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Please try again later. (label: `too-many-requests`)"}},"summary":"Remove an existing team member"},"get":{"description":" [internal route ID: \"get-team-member\"]\n\n","operationId":"get-team-member","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamMember_Optional_NTU0MDcyNzI1"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"no-team-member","message":"Requesting user is not a team member"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Requesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team-member","message":"Team member not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)"}},"summary":"Get single team member"}},"/teams/{tid}/search":{"get":{"description":" [internal route ID: \"browse-team\"]\n\n","operationId":"browse-team","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"Search expression","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"description":"Role filter, eg. `member,partner`. Empty list means do not filter.","in":"query","name":"frole","required":false,"schema":{"items":{"enum":["owner","admin","member","partner"],"type":"string"},"type":"array"}},{"description":"Can be one of name, handle, email, saml_idp, managed_by, role, created_at.","in":"query","name":"sortby","required":false,"schema":{"enum":["name","handle","email","saml_idp","managed_by","role","created_at"],"type":"string"}},{"description":"Can be one of asc, desc.","in":"query","name":"sortorder","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"description":"Number of results to return (min: 1, max: 500, default: 15)","in":"query","name":"size","required":false,"schema":{"maximum":500,"minimum":1,"type":"integer"}},{"description":"Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.","in":"query","name":"pagingState","required":false,"schema":{"type":"string"}},{"description":"Filter for (un-)verified email","in":"query","name":"email","required":false,"schema":{"enum":["unverified","verified"],"type":"string"}},{"description":"Optional, return only non-searchable members when false.","in":"query","name":"searchable","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SearchResult_TeamContact_LTE0NjQ0NzMw"}}},"description":"Search results"}},"summary":"Browse team for members (requires add-user permission)"}},"/teams/{tid}/search-visibility":{"get":{"description":" [internal route ID: \"get-search-visibility\"]\n\n","operationId":"get-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"description":""},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"operation-denied","message":"Insufficient permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"}},"summary":"Shows the value for search visibility"},"put":{"description":" [internal route ID: \"set-search-visibility\"]\n\n","operationId":"set-search-visibility","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSearchVisibilityView_Mzg3MzMzMTk3"}}},"required":true},"responses":{"204":{"description":"Search visibility set"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"team-search-visibility-not-enabled","message":"Custom search is not available for this team"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["team-search-visibility-not-enabled","operation-denied","no-team-member"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"no-team","message":"Team not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["no-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`tid` not found\n\nTeam not found (label: `no-team`)"}},"summary":"Sets the search visibility for the whole team"}},"/teams/{tid}/size":{"get":{"description":" [internal route ID: \"get-team-size\"]\n\nCan be out of sync by roughly the `refresh_interval` of the ES index.","operationId":"get-team-size","parameters":[{"in":"path","name":"tid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/TeamSize_LTMzMzk2MTk1"}}},"description":"Number of team members"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"invalid-invitation-code","message":"Invalid invitation code."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["invalid-invitation-code"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid invitation code. (label: `invalid-invitation-code`)"}},"summary":"Get the number of team members as an integer"}},"/time":{"get":{"description":" [internal route ID: \"get-server-time\"]\n\nReturns the current server time in UTC with seconds precision.","operationId":"get-server-time","responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ServerTime_LTM4NTI3MzIx"}}},"description":""}},"summary":"Get the current server time"}},"/upgrade-personal-to-team":{"post":{"description":" [internal route ID: \"upgrade-personal-to-team\"]\n\n","operationId":"upgrade-personal-to-team","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/BindingNewTeamUser_LTY0MDQxMDEw"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CreateUserTeam_MzI4NDQ1Mzkw"}}},"description":"Team created"},"403":{"content":{"application/json":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-already-in-a-team","message":"Switching teams is not allowed"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-already-in-a-team"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Switching teams is not allowed (label: `user-already-in-a-team`)"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"User not found (label: `not-found`)"}},"summary":"Upgrade personal user to team owner"}},"/user-groups":{"get":{"description":" [internal route ID: \"get-user-groups\"]\n\n","operationId":"get-user-groups","parameters":[{"description":"Search string","in":"query","name":"q","required":false,"schema":{"type":"string"}},{"in":"query","name":"sort_by","required":false,"schema":{"enum":["name","created_at"],"type":"string"}},{"in":"query","name":"sort_order","required":false,"schema":{"enum":["asc","desc"],"type":"string"}},{"in":"query","name":"page_size","required":false,"schema":{"description":"integer from [1..500]","type":"number"}},{"description":"`name` of the last seen user group, used to get the next page when sorting by name.","in":"query","name":"last_seen_name","required":false,"schema":{"maxLength":4000,"minLength":1,"type":"string"}},{"description":"`created_at` field of the last seen user group, used to get the next page when sorting by created_at.","in":"query","name":"last_seen_created_at","required":false,"schema":{"format":"yyyy-mm-ddThh:MM:ssZ","type":"string"}},{"description":"`id` of the last seen group, used to get the next page. **Must** be sent to get the next page.","in":"query","name":"last_seen_id","required":false,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}},{"allowEmptyValue":true,"in":"query","name":"include_member_count","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupPage_UserGroup_Const_LTMxNDg5MDAy"}}},"description":""}},"summary":"Fetch groups accessible to the logged-in user"},"post":{"description":" [internal route ID: \"create-user-group\"]\n\n","operationId":"create-user-group","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/NewUserGroup_MzYxODU0OTU1"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":""},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"}}}},"/user-groups/check-name":{"post":{"description":" [internal route ID: \"check-user-group-name-available\"]\n\n","operationId":"check-user-group-name-available","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/CheckUserGroupName_LTg0ODU1OTk1"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupNameAvailability_LTYzMDE1NTk4"}}},"description":"OK"}},"summary":"[STUB] Check if a user group name is available"}},"/user-groups/{gid}":{"delete":{"description":" [internal route ID: \"delete-user-group\"]\n\n","operationId":"delete-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User group deleted"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"get":{"description":" [internal route ID: \"get-user-group\"]\n\n","operationId":"get-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"include_channels","schema":{"default":false,"type":"boolean"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroup_Identity_NTg4MTY1MjEx"}}},"description":"User Group Found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or User group not found (label: `user-group-not-found`)\n\nUser group not found (label: `user-group-not-found`)"}},"summary":"Fetch a group accessible to the logged-in user"},"put":{"description":" [internal route ID: \"update-user-group\"]\n\n","operationId":"update-user-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupUpdate_MjUyNTA3Mjgy"}}},"required":true},"responses":{"200":{"description":"User added updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/user-groups/{gid}/channels":{"put":{"description":" [internal route ID: \"update-user-group-channels\"]\n\n","operationId":"update-user-group-channels","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"allowEmptyValue":true,"in":"query","name":"append_only","schema":{"default":false,"type":"boolean"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupChannels_LTIyMjcwMTMx"}}},"required":true},"responses":{"200":{"description":"User group channels updated"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}},"summary":"Replaces the channels with the given list."}},"/user-groups/{gid}/users":{"post":{"description":" [internal route ID: \"add-users-to-group-bulk\"]\n\n","operationId":"add-users-to-group-bulk","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserGroupAddUsers_LTgzOTYzNzk0"}}},"required":true},"responses":{"204":{"description":"Users added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Invalid `body`\n\nOnly team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"put":{"description":" [internal route ID: \"update-user-group-members\"]\n\n","operationId":"update-user-group-members","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UpdateUserGroupMembers_LTg1MzQ2NDY3"}}},"required":true},"responses":{"200":{"description":"User group members updated"}},"summary":"[STUB] Update user group members. Replaces the users with the given list."}},"/user-groups/{gid}/users/{uid}":{"delete":{"description":" [internal route ID: \"remove-user-from-group\"]\n\n","operationId":"remove-user-from-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User removed from group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}},"post":{"description":" [internal route ID: \"add-user-to-group\"]\n\n","operationId":"add-user-to-group","parameters":[{"in":"path","name":"gid","required":true,"schema":{"format":"uuid","type":"string"}},{"in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"204":{"description":"User added to group"},"400":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":400,"label":"user-group-invalid","message":"Only team members of the same team can be added to a user group."},"properties":{"code":{"enum":[400],"type":"integer"},"label":{"enum":["user-group-invalid"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team members of the same team can be added to a user group. (label: `user-group-invalid`)"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"user-group-write-forbidden","message":"Only team admins can create, update, or delete user groups."},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["user-group-write-forbidden"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Only team admins can create, update, or delete user groups. (label: `user-group-write-forbidden`)"},"404":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"user-group-not-found","message":"User group not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["user-group-not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`gid` or `uid` not found\n\nUser group not found (label: `user-group-not-found`)"}}}},"/users/list-clients":{"post":{"description":" [internal route ID: \"list-clients-bulk@v2\"]\n\nIf a backend is unreachable, the clients from that backend will be omitted from the response","operationId":"list-clients-bulk@v2","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/LimitedQualifiedUserIdList_500"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"properties":{"qualified_user_map":{"$ref":"#/components/schemas/QualifiedUserMap_Set_PubClient"}},"type":"object"}}},"description":""}},"summary":"List all clients for a set of user ids"}},"/users/list-prekeys":{"post":{"description":" [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\nYou can't request information for more users than maximum conversation size.","operationId":"get-multi-user-prekey-bundle-qualified","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClients"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/QualifiedUserClientPrekeyMapV4_MzcxOTAxMTYy"}}},"description":""}},"summary":"(deprecated) Given a map of user IDs to client IDs return a prekey for each one."}},"/users/{uid_domain}/{uid}":{"get":{"description":" [internal route ID: \"get-user-qualified\"]\n\n","operationId":"get-user-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/UserProfile_LTQzMTQxMTE1"}}},"description":"User found"},"404":{"content":{"application/json":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}},"application/json;charset=utf-8":{"schema":{"example":{"code":404,"label":"not-found","message":"User not found"},"properties":{"code":{"enum":[404],"type":"integer"},"label":{"enum":["not-found"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"`uid_domain` or `uid` or User not found (label: `not-found`)"}},"summary":"Get a user by Domain and UserId"}},"/users/{uid_domain}/{uid}/clients/{client}":{"get":{"description":" [internal route ID: \"get-user-client-qualified\"]\n\nHint: to list all clients of one or more users, use POST /users/list-clients.","operationId":"get-user-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PubClient"}}},"description":""}},"summary":"Get a specific client of a user"}},"/users/{uid_domain}/{uid}/prekeys":{"get":{"description":" [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\n","operationId":"get-users-prekey-bundle-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/PrekeyBundle_MzgzOTk4MjYz"}}},"description":""}},"summary":"Get a prekey for each client of a user."}},"/users/{uid_domain}/{uid}/prekeys/{client}":{"get":{"description":" [internal route ID: \"get-users-prekeys-client-qualified\"]\n\n","operationId":"get-users-prekeys-client-qualified","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}},{"description":"ClientId","in":"path","name":"client","required":true,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/ClientPrekey_LTcyODUzMTcw"}}},"description":""}},"summary":"Get a prekey for a specific client of a user."}},"/users/{uid_domain}/{uid}/supported-protocols":{"get":{"description":" [internal route ID: \"get-supported-protocols\"]\n\n","operationId":"get-supported-protocols","parameters":[{"in":"path","name":"uid_domain","required":true,"schema":{"type":"string"}},{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}},"application/json;charset=utf-8":{"schema":{"items":{"$ref":"#/components/schemas/BaseProtocolTag_LTM0MDE1NTEx"},"type":"array","uniqueItems":true}}},"description":"Protocols supported by the user"}},"summary":"Get a user's supported protocols"}},"/users/{uid}/email":{"put":{"description":" [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.","operationId":"update-user-email","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/EmailUpdate_NjQ5MDg1OTY0"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Resend email address validation email."}},"/users/{uid}/rich-info":{"get":{"description":" [internal route ID: \"get-rich-info\"]\n\n","operationId":"get-rich-info","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}},"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/RichInfoAssocList"}}},"description":"Rich info about the user"},"403":{"content":{"application/json;charset=utf-8":{"schema":{"example":{"code":403,"label":"insufficient-permissions","message":"Insufficient team permissions"},"properties":{"code":{"enum":[403],"type":"integer"},"label":{"enum":["insufficient-permissions"],"type":"string"},"message":{"type":"string"}},"required":["code","label","message"],"type":"object"}}},"description":"Insufficient team permissions (label: `insufficient-permissions`)"}},"summary":"Get a user's rich info"}},"/users/{uid}/searchable":{"post":{"description":" [internal route ID: \"set-user-searchable\"]\n\n","operationId":"set-user-searchable","parameters":[{"description":"User Id","in":"path","name":"uid","required":true,"schema":{"format":"uuid","type":"string"}}],"requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SetSearchable_NDAxODAxODI5"}}},"required":true},"responses":{"200":{"content":{"application/json;charset=utf-8":{"schema":{"example":[],"items":{},"maxItems":0,"type":"array"}}},"description":""}},"summary":"Set user's visibility in search"}},"/verification-code/send":{"post":{"description":" [internal route ID: \"send-verification-code\"]\n\n","operationId":"send-verification-code","requestBody":{"content":{"application/json;charset=utf-8":{"schema":{"$ref":"#/components/schemas/SendVerificationCode_MjgxNDgxODE2"}}},"required":true},"responses":{"200":{"description":"Verification code sent."}},"summary":"Send a verification code to a given email address."}},"/websocket":{"get":{"description":" [internal route ID: \"websocket\"]\n\nThis is a temporary copy of await, please do not use it","externalDocs":{"description":"RFC 6455","url":"https://datatracker.ietf.org/doc/html/rfc6455"},"operationId":"websocket","parameters":[{"description":"Client ID","in":"query","name":"client","required":false,"schema":{"type":"string"}}],"responses":{"101":{"description":"Connection upgraded."},"426":{"description":"Upgrade required."}},"summary":"Establish websocket connection"}}},"security":[{"ZAuth":[]}],"servers":[{"url":"/v16"}]} diff --git a/services/brig/docs/swagger-v17.json b/services/brig/docs/swagger-v17.json new file mode 100644 index 00000000000..2c63648dc97 --- /dev/null +++ b/services/brig/docs/swagger-v17.json @@ -0,0 +1,31087 @@ +{ + "components": { + "schemas": { + "": { + "enum": [ + "audio", + "books", + "business", + "design", + "education", + "entertainment", + "finance", + "fitness", + "food-drink", + "games", + "graphics", + "health", + "integration", + "lifestyle", + "media", + "medical", + "movies", + "music", + "news", + "photography", + "poll", + "productivity", + "quiz", + "rating", + "shopping", + "social", + "sports", + "travel", + "tutorial", + "video", + "weather" + ], + "type": "string" + }, + "ASCII": { + "example": "aGVsbG8", + "type": "string" + }, + "Access": { + "description": "How users can join conversations", + "enum": [ + "private", + "invite", + "link", + "code" + ], + "type": "string" + }, + "AccessRole": { + "description": "Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.", + "enum": [ + "team_member", + "non_team_member", + "guest", + "service" + ], + "type": "string" + }, + "AccessRoleLegacy": { + "deprecated": true, + "description": "Deprecated, please use access_role_v2", + "enum": [ + "private", + "team", + "activated", + "non_activated" + ], + "type": "string" + }, + "AccessToken": { + "properties": { + "access_token": { + "description": "The opaque access token string", + "type": "string" + }, + "expires_in": { + "description": "The number of seconds this token is valid", + "type": "integer" + }, + "token_type": { + "$ref": "#/components/schemas/TokenType" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "access_token", + "token_type", + "expires_in" + ], + "type": "object" + }, + "AccessTokenType": { + "enum": [ + "DPoP" + ], + "type": "string" + }, + "Action": { + "enum": [ + "add_conversation_member", + "remove_conversation_member", + "modify_conversation_name", + "modify_conversation_message_timer", + "modify_conversation_receipt_mode", + "modify_conversation_access", + "modify_other_conversation_member", + "leave_conversation", + "delete_conversation" + ], + "type": "string" + }, + "Activate": { + "description": "Data for an activation request.", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "dryrun": { + "description": "At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.", + "type": "boolean" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "code", + "dryrun" + ], + "type": "object" + }, + "ActivationResponse": { + "description": "Response body of a successful activation request", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "first": { + "description": "Whether this is the first successful activation (i.e. account activation).", + "type": "boolean" + }, + "sso_id": { + "$ref": "#/components/schemas/UserSSOId" + } + }, + "type": "object" + }, + "AddBot": { + "properties": { + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "provider": { + "$ref": "#/components/schemas/UUID" + }, + "service": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "provider", + "service" + ], + "type": "object" + }, + "AddBotResponse": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "client": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "event": { + "$ref": "#/components/schemas/Event" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "client", + "name", + "accent_id", + "assets", + "event" + ], + "type": "object" + }, + "AllTeamFeatures": { + "properties": { + "appLock": { + "$ref": "#/components/schemas/AppLockConfig.LockableFeature" + }, + "classifiedDomains": { + "$ref": "#/components/schemas/ClassifiedDomainsConfig.LockableFeature" + }, + "conferenceCalling": { + "$ref": "#/components/schemas/ConferenceCallingConfig.LockableFeature" + }, + "conversationGuestLinks": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + }, + "digitalSignatures": { + "$ref": "#/components/schemas/DigitalSignaturesConfig.LockableFeature" + }, + "enforceFileDownloadLocation": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation.LockableFeature" + }, + "exposeInvitationURLsToTeamAdmin": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.LockableFeature" + }, + "fileSharing": { + "$ref": "#/components/schemas/FileSharingConfig.LockableFeature" + }, + "legalhold": { + "$ref": "#/components/schemas/LegalholdConfig.LockableFeature" + }, + "limitedEventFanout": { + "$ref": "#/components/schemas/LimitedEventFanoutConfig.LockableFeature" + }, + "mls": { + "$ref": "#/components/schemas/MLSConfig.LockableFeature" + }, + "mlsE2EId": { + "$ref": "#/components/schemas/MlsE2EIdConfig.LockableFeature" + }, + "mlsMigration": { + "$ref": "#/components/schemas/MlsMigration.LockableFeature" + }, + "outlookCalIntegration": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.LockableFeature" + }, + "searchVisibility": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.LockableFeature" + }, + "searchVisibilityInbound": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.LockableFeature" + }, + "selfDeletingMessages": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.LockableFeature" + }, + "sndFactorPasswordChallenge": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.LockableFeature" + }, + "sso": { + "$ref": "#/components/schemas/SSOConfig.LockableFeature" + }, + "validateSAMLemails": { + "$ref": "#/components/schemas/ValidateSAMLEmailsConfig.LockableFeature" + } + }, + "required": [ + "legalhold", + "sso", + "searchVisibility", + "searchVisibilityInbound", + "validateSAMLemails", + "digitalSignatures", + "appLock", + "fileSharing", + "classifiedDomains", + "conferenceCalling", + "selfDeletingMessages", + "conversationGuestLinks", + "sndFactorPasswordChallenge", + "mls", + "exposeInvitationURLsToTeamAdmin", + "outlookCalIntegration", + "mlsE2EId", + "mlsMigration", + "enforceFileDownloadLocation", + "limitedEventFanout" + ], + "type": "object" + }, + "Alpha": { + "enum": [ + "AED", + "AFN", + "ALL", + "AMD", + "ANG", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BGN", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOV", + "BRL", + "BSD", + "BTN", + "BWP", + "BYN", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLF", + "CLP", + "CNY", + "COP", + "COU", + "CRC", + "CUC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HRK", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRO", + "MUR", + "MVR", + "MWK", + "MXN", + "MXV", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLL", + "SOS", + "SRD", + "SSP", + "STD", + "SVC", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "USN", + "UYI", + "UYU", + "UZS", + "VEF", + "VND", + "VUV", + "WST", + "XAF", + "XAG", + "XAU", + "XBA", + "XBB", + "XBC", + "XBD", + "XCD", + "XDR", + "XOF", + "XPD", + "XPF", + "XPT", + "XSU", + "XTS", + "XUA", + "XXX", + "YER", + "ZAR", + "ZMW", + "ZWL" + ], + "type": "string" + }, + "AppLockConfig": { + "properties": { + "enforceAppLock": { + "type": "boolean" + }, + "inactivityTimeoutSecs": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "enforceAppLock", + "inactivityTimeoutSecs" + ], + "type": "object" + }, + "AppLockConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/AppLockConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "AppLockConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/AppLockConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "ApproveLegalHoldForUserRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "Asset": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "expires": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "key": { + "$ref": "#/components/schemas/AssetKey" + }, + "token": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "key", + "domain" + ], + "type": "object" + }, + "AssetKey": { + "example": "3-1-47de4580-ae51-4650-acbb-d10c028cb0ac", + "type": "string" + }, + "AssetSize": { + "enum": [ + "preview", + "complete" + ], + "type": "string" + }, + "AssetSource": {}, + "AssetType": { + "enum": [ + "image" + ], + "type": "string" + }, + "AuthnRequest": { + "properties": { + "iD": { + "$ref": "#/components/schemas/ID_*_AuthnRequest" + }, + "issueInstant": { + "$ref": "#/components/schemas/Time" + }, + "issuer": { + "type": "string" + }, + "nameIDPolicy": { + "$ref": "#/components/schemas/NameIdPolicy" + } + }, + "required": [ + "iD", + "issueInstant", + "issuer" + ], + "type": "object" + }, + "Base64ByteString": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "BaseProtocol": { + "enum": [ + "proteus", + "mls" + ], + "type": "string" + }, + "BindingNewTeamUser": { + "properties": { + "currency": { + "$ref": "#/components/schemas/Alpha" + }, + "icon": { + "$ref": "#/components/schemas/Icon" + }, + "icon_key": { + "description": "team icon asset key", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "members": { + "description": "initial team member ids (between 1 and 127)" + }, + "name": { + "description": "team name", + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "icon" + ], + "type": "object" + }, + "Body": {}, + "BotConvView": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "members": { + "items": { + "$ref": "#/components/schemas/OtherMember" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "members" + ], + "type": "object" + }, + "BotUserView": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id", + "name", + "accent_id" + ], + "type": "object" + }, + "CheckHandles": { + "properties": { + "handles": { + "items": { + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + "return": { + "maximum": 10, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "handles", + "return" + ], + "type": "object" + }, + "CipherSuiteTag": { + "description": "The cipher suite of the corresponding MLS group", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "ClassifiedDomainsConfig": { + "properties": { + "domains": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "domains" + ], + "type": "object" + }, + "ClassifiedDomainsConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/ClassifiedDomainsConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "Client": { + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ClientCapabilityList" + }, + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "cookie": { + "type": "string" + }, + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "label": { + "type": "string" + }, + "last_active": { + "$ref": "#/components/schemas/UTCTime" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "type": { + "$ref": "#/components/schemas/ClientType" + } + }, + "required": [ + "id", + "type", + "time" + ], + "type": "object" + }, + "ClientCapability": { + "enum": [ + "legalhold-implicit-consent" + ], + "type": "string" + }, + "ClientCapabilityList": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/components/schemas/ClientCapability" + }, + "type": "array" + } + }, + "required": [ + "capabilities" + ], + "type": "object" + }, + "ClientClass": { + "enum": [ + "phone", + "tablet", + "desktop", + "legalhold" + ], + "type": "string" + }, + "ClientIdentity": { + "properties": { + "client_id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "user_id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "user_id", + "client_id" + ], + "type": "object" + }, + "ClientListv6": { + "items": { + "$ref": "#/components/schemas/Client" + }, + "type": "array" + }, + "ClientMismatch": { + "properties": { + "deleted": { + "$ref": "#/components/schemas/UserClients" + }, + "missing": { + "$ref": "#/components/schemas/UserClients" + }, + "redundant": { + "$ref": "#/components/schemas/UserClients" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + } + }, + "required": [ + "time", + "missing", + "redundant", + "deleted" + ], + "type": "object" + }, + "ClientPrekey": { + "properties": { + "client": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "prekey": { + "$ref": "#/components/schemas/Prekey" + } + }, + "required": [ + "client", + "prekey" + ], + "type": "object" + }, + "ClientType": { + "enum": [ + "temporary", + "permanent", + "legalhold" + ], + "type": "string" + }, + "Clientv6": { + "properties": { + "capabilities": { + "$ref": "#/components/schemas/ClientCapabilityList" + }, + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "cookie": { + "type": "string" + }, + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "label": { + "type": "string" + }, + "last_active": { + "$ref": "#/components/schemas/UTCTime" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "type": { + "$ref": "#/components/schemas/ClientType" + } + }, + "required": [ + "id", + "type", + "time" + ], + "type": "object" + }, + "CodeChallengeMethod": { + "description": "The method used to encode the code challenge. Only `S256` is supported.", + "enum": [ + "S256" + ], + "type": "string" + }, + "CommitBundle": { + "description": "This object can only be parsed in TLS format. Please refer to the MLS specification for details." + }, + "CompletePasswordReset": { + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "key", + "code", + "password" + ], + "type": "object" + }, + "ConferenceCallingConfig": { + "properties": { + "useSFTForOneToOneCalls": { + "type": "boolean" + } + }, + "type": "object" + }, + "ConferenceCallingConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/ConferenceCallingConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ConferenceCallingConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/ConferenceCallingConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "Connect": { + "properties": { + "email": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "qualified_recipient": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "recipient": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "qualified_recipient" + ], + "type": "object" + }, + "ConnectionUpdate": { + "properties": { + "status": { + "$ref": "#/components/schemas/Relation" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "Connections_Page": { + "properties": { + "connections": { + "items": { + "$ref": "#/components/schemas/UserConnection" + }, + "type": "array" + }, + "has_more": { + "type": "boolean" + }, + "paging_state": { + "$ref": "#/components/schemas/Connections_PagingState" + } + }, + "required": [ + "connections", + "has_more", + "paging_state" + ], + "type": "object" + }, + "Connections_PagingState": { + "type": "string" + }, + "Contact": { + "description": "Contact discovered through search", + "properties": { + "accent_id": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "handle": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "type": "string" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "qualified_id", + "name" + ], + "type": "object" + }, + "ConvMembers": { + "description": "Users of a conversation", + "properties": { + "others": { + "description": "All other current users of this conversation", + "items": { + "$ref": "#/components/schemas/OtherMember" + }, + "type": "array" + }, + "self": { + "$ref": "#/components/schemas/Member" + } + }, + "required": [ + "self", + "others" + ], + "type": "object" + }, + "ConvTeamInfo": { + "description": "Team information of this conversation", + "properties": { + "managed": { + "description": "This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface." + }, + "teamid": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "teamid", + "managed" + ], + "type": "object" + }, + "ConvType": { + "enum": [ + 0, + 1, + 2, + 3 + ], + "type": "integer" + }, + "Conversation": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch" + ], + "type": "object" + }, + "ConversationAccessData": { + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + } + }, + "required": [ + "access", + "access_role" + ], + "type": "object" + }, + "ConversationAccessDataV2": { + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/components/schemas/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + } + }, + "required": [ + "access" + ], + "type": "object" + }, + "ConversationCode": { + "description": "Contains conversation properties to update", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "ConversationCodeInfo": { + "description": "Contains conversation properties to update", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "has_password": { + "description": "Whether the conversation has a password", + "type": "boolean" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "key", + "code", + "has_password" + ], + "type": "object" + }, + "ConversationCoverView": { + "description": "Limited view of Conversation.", + "properties": { + "has_password": { + "type": "boolean" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "has_password" + ], + "type": "object" + }, + "ConversationIds_Page": { + "properties": { + "has_more": { + "type": "boolean" + }, + "paging_state": { + "$ref": "#/components/schemas/ConversationIds_PagingState" + }, + "qualified_conversations": { + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "type": "array" + } + }, + "required": [ + "qualified_conversations", + "has_more", + "paging_state" + ], + "type": "object" + }, + "ConversationIds_PagingState": { + "type": "string" + }, + "ConversationMessageTimerUpdate": { + "description": "Contains conversation properties to update", + "properties": { + "message_timer": { + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "type": "object" + }, + "ConversationReceiptModeUpdate": { + "description": "Contains conversation receipt mode to update to. Receipt mode tells clients whether certain types of receipts should be sent in the given conversation or not. How this value is interpreted is up to clients.", + "properties": { + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "receipt_mode" + ], + "type": "object" + }, + "ConversationRename": { + "properties": { + "name": { + "description": "The new conversation name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ConversationRole": { + "properties": { + "actions": { + "description": "The set of actions allowed for this role", + "items": { + "$ref": "#/components/schemas/Action" + }, + "type": "array" + }, + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + } + } + }, + "ConversationRolesList": { + "properties": { + "conversation_roles": { + "items": { + "$ref": "#/components/schemas/ConversationRole" + }, + "type": "array" + } + }, + "required": [ + "conversation_roles" + ], + "type": "object" + }, + "ConversationV2": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/components/schemas/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite" + ], + "type": "object" + }, + "ConversationV3v3": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite" + ], + "type": "object" + }, + "ConversationV6v6": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch" + ], + "type": "object" + }, + "ConversationsResponse": { + "description": "Response object for getting metadata of a list of conversations", + "properties": { + "failed": { + "description": "The server failed to fetch these conversations, most likely due to network issues while contacting a remote server", + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "type": "array" + }, + "found": { + "items": { + "$ref": "#/components/schemas/Conversation" + }, + "type": "array" + }, + "not_found": { + "description": "These conversations either don't exist or are deleted.", + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "type": "array" + } + }, + "required": [ + "found", + "not_found", + "failed" + ], + "type": "object" + }, + "Cookie": { + "properties": { + "created": { + "$ref": "#/components/schemas/UTCTime" + }, + "expires": { + "$ref": "#/components/schemas/UTCTime" + }, + "id": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "label": { + "type": "string" + }, + "successor": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "type": { + "$ref": "#/components/schemas/CookieType" + } + }, + "required": [ + "id", + "type", + "created", + "expires" + ], + "type": "object" + }, + "CookieList": { + "description": "List of cookie information", + "properties": { + "cookies": { + "items": { + "$ref": "#/components/schemas/Cookie" + }, + "type": "array" + } + }, + "required": [ + "cookies" + ], + "type": "object" + }, + "CookieType": { + "enum": [ + "session", + "persistent" + ], + "type": "string" + }, + "CreateConversationCodeRequest": { + "description": "Request body for creating a conversation code", + "properties": { + "password": { + "description": "Password for accessing the conversation via guest link. Set to null or omit for no password.", + "maxLength": 1024, + "minLength": 8, + "type": "string" + } + }, + "type": "object" + }, + "CreateGroupConversationv6": { + "description": "A created group-conversation object extended with a list of failed-to-add users", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "failed_to_add": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch", + "failed_to_add" + ], + "type": "object" + }, + "CreateOAuthAuthorizationCodeRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "code_challenge": { + "$ref": "#/components/schemas/OAuthCodeChallenge" + }, + "code_challenge_method": { + "$ref": "#/components/schemas/CodeChallengeMethod" + }, + "redirect_uri": { + "$ref": "#/components/schemas/RedirectUrl" + }, + "response_type": { + "$ref": "#/components/schemas/OAuthResponseType" + }, + "scope": { + "description": "The scopes which are requested to get authorization for, separated by a space", + "type": "string" + }, + "state": { + "description": "An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery", + "type": "string" + } + }, + "required": [ + "client_id", + "scope", + "response_type", + "redirect_uri", + "state", + "code_challenge_method", + "code_challenge" + ], + "type": "object" + }, + "CreateScimToken": { + "properties": { + "description": { + "type": "string" + }, + "password": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + }, + "required": [ + "description" + ], + "type": "object" + }, + "CreateScimTokenResponse": { + "properties": { + "info": { + "$ref": "#/components/schemas/ScimTokenInfo" + }, + "token": { + "description": "Authentication token", + "type": "string" + } + }, + "required": [ + "token", + "info" + ], + "type": "object" + }, + "CustomBackend": { + "description": "Description of a custom backend", + "properties": { + "config_json_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "webapp_welcome_url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "config_json_url", + "webapp_welcome_url" + ], + "type": "object" + }, + "DPoPAccessToken": { + "type": "string" + }, + "DPoPAccessTokenResponse": { + "properties": { + "expires_in": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "token": { + "$ref": "#/components/schemas/DPoPAccessToken" + }, + "type": { + "$ref": "#/components/schemas/AccessTokenType" + } + }, + "required": [ + "token", + "type", + "expires_in" + ], + "type": "object" + }, + "DeleteClient": { + "properties": { + "password": { + "description": "The password of the authenticated user for verification. The password is not required for deleting temporary clients.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "DeleteKeyPackages": { + "properties": { + "key_packages": { + "items": { + "$ref": "#/components/schemas/KeyPackageRef" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "key_packages" + ], + "type": "object" + }, + "DeleteProvider": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "DeleteService": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "DeleteSubConversationRequest": { + "description": "Delete an MLS subconversation", + "properties": { + "epoch": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + } + }, + "required": [ + "group_id", + "epoch" + ], + "type": "object" + }, + "DeleteUser": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "DeletionCodeTimeout": { + "properties": { + "expires_in": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "expires_in" + ], + "type": "object" + }, + "DeprecatedMatchingResult": { + "deprecated": true, + "properties": { + "auto-connects": { + "items": {}, + "type": "array" + }, + "results": { + "items": {}, + "type": "array" + } + }, + "required": [ + "results", + "auto-connects" + ], + "type": "object" + }, + "DigitalSignaturesConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "DisableLegalHoldForUserRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "Domain": { + "example": "example.com", + "type": "string" + }, + "EdMemberLeftReason": { + "enum": [ + "left", + "user-deleted", + "removed" + ], + "type": "string" + }, + "Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest": { + "oneOf": [ + { + "properties": { + "Left": { + "$ref": "#/components/schemas/OAuthAccessTokenRequest" + } + }, + "required": [ + "Left" + ], + "title": "Left", + "type": "object" + }, + { + "properties": { + "Right": { + "$ref": "#/components/schemas/OAuthRefreshAccessTokenRequest" + } + }, + "required": [ + "Right" + ], + "title": "Right", + "type": "object" + } + ] + }, + "Email": { + "type": "string" + }, + "EmailUpdate": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "EnforceFileDownloadLocation": { + "properties": { + "enforcedDownloadLocation": { + "type": "string" + } + }, + "type": "object" + }, + "EnforceFileDownloadLocation.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "EnforceFileDownloadLocation.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "Epoch Timestamp": { + "example": "2021-05-12T10:52:02Z", + "format": "yyyy-mm-ddThh:MM:ssZ", + "type": "string" + }, + "Event": { + "properties": { + "conversation": { + "$ref": "#/components/schemas/UUID" + }, + "data": { + "description": "Encrypted message of a conversation", + "example": "ZXhhbXBsZQo=", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/components/schemas/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "data": { + "description": "Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.", + "type": "string" + }, + "email": { + "type": "string" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "has_password": { + "description": "Whether the conversation has a password", + "type": "boolean" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/components/schemas/ConvMembers" + }, + "message": { + "type": "string" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "protocol": { + "$ref": "#/components/schemas/Protocol" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "qualified_recipient": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "qualified_target": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "qualified_user_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "reason": { + "$ref": "#/components/schemas/EdMemberLeftReason" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "recipient": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "sender": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/TypingStatus" + }, + "target": { + "$ref": "#/components/schemas/UUID" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "text": { + "description": "The ciphertext for the recipient (Base64 in JSON)", + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/ConvType" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "user_ids": { + "deprecated": true, + "description": "Deprecated, use qualified_user_ids", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "users": { + "items": { + "$ref": "#/components/schemas/SimpleMember" + }, + "type": "array" + } + }, + "required": [ + "users", + "reason", + "qualified_user_ids", + "user_ids", + "qualified_target", + "name", + "access", + "key", + "code", + "has_password", + "qualified_id", + "type", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite", + "qualified_recipient", + "receipt_mode", + "sender", + "recipient", + "text", + "status" + ], + "type": "object" + }, + "from": { + "$ref": "#/components/schemas/UUID" + }, + "qualified_conversation": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "qualified_from": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "subconv": { + "type": "string" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "type": { + "$ref": "#/components/schemas/EventType" + } + }, + "required": [ + "type", + "data", + "qualified_conversation", + "qualified_from", + "time" + ], + "type": "object" + }, + "EventType": { + "enum": [ + "conversation.member-join", + "conversation.member-leave", + "conversation.member-update", + "conversation.rename", + "conversation.access-update", + "conversation.receipt-mode-update", + "conversation.message-timer-update", + "conversation.code-update", + "conversation.code-delete", + "conversation.create", + "conversation.delete", + "conversation.connect-request", + "conversation.typing", + "conversation.otr-message-add", + "conversation.mls-message-add", + "conversation.mls-welcome", + "conversation.protocol-update" + ], + "type": "string" + }, + "ExposeInvitationURLsToTeamAdminConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ExposeInvitationURLsToTeamAdminConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "FeatureStatus": { + "enum": [ + "enabled", + "disabled" + ], + "type": "string" + }, + "FederatedUserSearchPolicy": { + "description": "Search policy that was applied when searching for users", + "enum": [ + "no_search", + "exact_handle_search", + "full_search" + ], + "type": "string" + }, + "FileSharingConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "FileSharingConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "Fingerprint": { + "example": "ioy3GeIjgQRsobf2EKGO3O8mq/FofFxHRqy0T4ERIZ8=", + "type": "string" + }, + "FormRedirect": { + "properties": { + "uri": { + "type": "string" + }, + "xml": { + "$ref": "#/components/schemas/AuthnRequest" + } + }, + "type": "object" + }, + "GetPaginated_Connections": { + "description": "A request to list some or all of a user's Connections, including remote ones", + "properties": { + "paging_state": { + "$ref": "#/components/schemas/Connections_PagingState" + }, + "size": { + "description": "optional, must be <= 500, defaults to 100.", + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "GetPaginated_ConversationIds": { + "description": "A request to list some or all of a user's ConversationIds, including remote ones", + "properties": { + "paging_state": { + "$ref": "#/components/schemas/ConversationIds_PagingState" + }, + "size": { + "description": "optional, must be <= 1000, defaults to 1000.", + "format": "int32", + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "GroupId": { + "description": "A base64-encoded MLS group ID", + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "GroupInfoData": { + "description": "This object can only be parsed in TLS format. Please refer to the MLS specification for details." + }, + "GuestLinksConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "GuestLinksConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "Handle": { + "type": "string" + }, + "HandleUpdate": { + "properties": { + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "HttpsUrl": { + "example": "https://example.com", + "type": "string" + }, + "ID_*_AuthnRequest": { + "properties": { + "iD": { + "$ref": "#/components/schemas/XmlText" + } + }, + "required": [ + "iD" + ], + "type": "object" + }, + "Icon": { + "type": "string" + }, + "Id": { + "properties": { + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "IdPConfig_WireIdP": { + "properties": { + "extraInfo": { + "$ref": "#/components/schemas/WireIdP" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "metadata": { + "$ref": "#/components/schemas/IdPMetadata" + } + }, + "required": [ + "id", + "metadata", + "extraInfo" + ], + "type": "object" + }, + "IdPList": { + "properties": { + "providers": { + "items": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + }, + "type": "array" + } + }, + "required": [ + "providers" + ], + "type": "object" + }, + "IdPMetadata": { + "properties": { + "certAuthnResponse": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "issuer": { + "type": "string" + }, + "requestURI": { + "type": "string" + } + }, + "required": [ + "issuer", + "requestURI", + "certAuthnResponse" + ], + "type": "object" + }, + "IdPMetadataInfo": { + "maxProperties": 1, + "minProperties": 1, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "Invitation": { + "description": "An invitation to join a team on Wire", + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "description": "Name of the invitee (1 - 128 characters)", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "url": { + "$ref": "#/components/schemas/URIRef Absolute" + } + }, + "required": [ + "team", + "id", + "created_at", + "email" + ], + "type": "object" + }, + "InvitationList": { + "description": "A list of sent team invitations.", + "properties": { + "has_more": { + "description": "Indicator that the server has more invitations than returned.", + "type": "boolean" + }, + "invitations": { + "items": { + "$ref": "#/components/schemas/Invitation" + }, + "type": "array" + } + }, + "required": [ + "invitations", + "has_more" + ], + "type": "object" + }, + "InvitationRequest": { + "description": "A request to join a team on Wire.", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "name": { + "description": "Name of the invitee (1 - 128 characters).", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "InviteQualified": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "qualified_users": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "qualified_users" + ], + "type": "object" + }, + "JoinConversationByCode": { + "description": "Request body for joining a conversation by code", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "uri": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "KeyPackage": { + "example": "a2V5IHBhY2thZ2UgZGF0YQo=", + "type": "string" + }, + "KeyPackageBundle": { + "properties": { + "key_packages": { + "items": { + "$ref": "#/components/schemas/KeyPackageBundleEntry" + }, + "type": "array" + } + }, + "required": [ + "key_packages" + ], + "type": "object" + }, + "KeyPackageBundleEntry": { + "properties": { + "client": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "key_package": { + "$ref": "#/components/schemas/KeyPackage" + }, + "key_package_ref": { + "$ref": "#/components/schemas/KeyPackageRef" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "user", + "client", + "key_package_ref", + "key_package" + ], + "type": "object" + }, + "KeyPackageRef": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "KeyPackageUpload": { + "properties": { + "key_packages": { + "items": { + "$ref": "#/components/schemas/KeyPackage" + }, + "type": "array" + } + }, + "required": [ + "key_packages" + ], + "type": "object" + }, + "LHServiceStatus": { + "enum": [ + "configured", + "not_configured", + "disabled" + ], + "type": "string" + }, + "LegalholdConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "LegalholdConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "LimitedEventFanoutConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "LimitedQualifiedUserIdList_500": { + "properties": { + "qualified_users": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + } + }, + "required": [ + "qualified_users" + ], + "type": "object" + }, + "List1": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "minItems": 1, + "type": "array" + }, + "ListConversations": { + "description": "A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs", + "properties": { + "qualified_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "qualified_ids" + ], + "type": "object" + }, + "ListType": { + "description": "true if 'members' doesn't contain all team members", + "enum": [ + true, + false + ], + "type": "boolean" + }, + "ListUsersById": { + "properties": { + "failed": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "minItems": 1, + "type": "array" + }, + "found": { + "items": { + "$ref": "#/components/schemas/UserProfile" + }, + "type": "array" + } + }, + "required": [ + "found" + ], + "type": "object" + }, + "ListUsersQuery": { + "description": "exactly one of qualified_ids or qualified_handles must be provided.", + "example": { + "qualified_ids": [ + { + "domain": "example.com", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "properties": { + "qualified_handles": { + "items": { + "$ref": "#/components/schemas/Qualified_Handle" + }, + "type": "array" + }, + "qualified_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + } + }, + "type": "object" + }, + "Locale": { + "type": "string" + }, + "LocaleUpdate": { + "properties": { + "locale": { + "$ref": "#/components/schemas/Locale" + } + }, + "required": [ + "locale" + ], + "type": "object" + }, + "LockStatus": { + "enum": [ + "locked", + "unlocked" + ], + "type": "string" + }, + "Login": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "label": { + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "verification_code": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "MLSConfig": { + "properties": { + "allowedCipherSuites": { + "items": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "type": "array" + }, + "defaultCipherSuite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "defaultProtocol": { + "$ref": "#/components/schemas/Protocol" + }, + "protocolToggleUsers": { + "description": "allowlist of users that may change protocols", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "supportedProtocols": { + "items": { + "$ref": "#/components/schemas/Protocol" + }, + "type": "array" + } + }, + "required": [ + "protocolToggleUsers", + "defaultProtocol", + "allowedCipherSuites", + "defaultCipherSuite", + "supportedProtocols" + ], + "type": "object" + }, + "MLSConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MLSConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "MLSConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MLSConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "MLSKeys": { + "properties": { + "ecdsa_secp256r1_sha256": { + "$ref": "#/components/schemas/SomeKey" + }, + "ecdsa_secp384r1_sha384": { + "$ref": "#/components/schemas/SomeKey" + }, + "ecdsa_secp521r1_sha512": { + "$ref": "#/components/schemas/SomeKey" + }, + "ed25519": { + "$ref": "#/components/schemas/SomeKey" + } + }, + "required": [ + "ed25519", + "ecdsa_secp256r1_sha256", + "ecdsa_secp384r1_sha384", + "ecdsa_secp521r1_sha512" + ], + "type": "object" + }, + "MLSKeysByPurpose": { + "properties": { + "removal": { + "$ref": "#/components/schemas/MLSKeys" + } + }, + "required": [ + "removal" + ], + "type": "object" + }, + "MLSMessage": { + "description": "This object can only be parsed in TLS format. Please refer to the MLS specification for details." + }, + "MLSMessageSendingStatus": { + "properties": { + "events": { + "description": "A list of events caused by sending the message.", + "items": { + "$ref": "#/components/schemas/Event" + }, + "type": "array" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + } + }, + "required": [ + "events", + "time" + ], + "type": "object" + }, + "MLSOne2OneConversation_MLSPublicKey": { + "properties": { + "conversation": { + "$ref": "#/components/schemas/Conversation" + }, + "public_keys": { + "$ref": "#/components/schemas/MLSKeysByPurpose" + } + }, + "required": [ + "conversation", + "public_keys" + ], + "type": "object" + }, + "MLSPublicKey": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "MLSPublicKeys": { + "additionalProperties": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "description": "Mapping from signature scheme (tags) to public key data", + "example": { + "ecdsa_secp256r1_sha256": "ZXhhbXBsZQo=", + "ecdsa_secp384r1_sha384": "ZXhhbXBsZQo=", + "ecdsa_secp521r1_sha512": "ZXhhbXBsZQo=", + "ed25519": "ZXhhbXBsZQo=" + }, + "type": "object" + }, + "ManagedBy": { + "enum": [ + "wire", + "scim" + ], + "type": "string" + }, + "Member": { + "description": "The user ID of the requestor", + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "status": {}, + "status_ref": {}, + "status_time": {} + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "MemberUpdate": { + "properties": { + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "type": "object" + }, + "MemberUpdateData": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "qualified_target": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "target": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "qualified_target" + ], + "type": "object" + }, + "MessageSendingStatus": { + "description": "The Proteus message sending status. It has these fields:\n- `time`: Time of sending message.\n- `missing`: Clients that the message /should/ have been encrypted for, but wasn't.\n- `redundant`: Clients that the message /should not/ have been encrypted for, but was.\n- `deleted`: Clients that were deleted.\n- `failed_to_send`: When message sending fails for some clients but succeeds for others, e.g., because a remote backend is unreachable, this field will contain the list of clients for which the message sending failed. This list should be empty when message sending is not even tried, like when some clients are missing.", + "properties": { + "deleted": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "failed_to_confirm_clients": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "failed_to_send": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "missing": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "redundant": { + "$ref": "#/components/schemas/QualifiedUserClients" + }, + "time": { + "$ref": "#/components/schemas/UTCTimeMillis" + } + }, + "required": [ + "time", + "missing", + "redundant", + "deleted", + "failed_to_send", + "failed_to_confirm_clients" + ], + "type": "object" + }, + "MlsE2EIdConfig": { + "properties": { + "acmeDiscoveryUrl": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "crlProxy": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "useProxyOnMobile": { + "type": "boolean" + }, + "verificationExpiration": { + "description": "When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can \"snooze\" this login. The duration of this grace period (in seconds) is set in the `verificationDuration` parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s). The client enrolls using the Automatic Certificate Management Environment (ACME) protocol. The `acmeDiscoveryUrl` parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form \"https://acme.{backendDomain}/acme/{provisionerName}/discovery\". For example: `https://acme.example.com/acme/provisioner1/discovery`.", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "verificationExpiration" + ], + "type": "object" + }, + "MlsE2EIdConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsE2EIdConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "MlsE2EIdConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsE2EIdConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "MlsMigration": { + "properties": { + "finaliseRegardlessAfter": { + "$ref": "#/components/schemas/UTCTime" + }, + "startTime": { + "$ref": "#/components/schemas/UTCTime" + } + }, + "type": "object" + }, + "MlsMigration.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsMigration" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "MlsMigration.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/MlsMigration" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "NameIDFormat": { + "enum": [ + "NameIDFUnspecified", + "NameIDFEmail", + "NameIDFX509", + "NameIDFWindows", + "NameIDFKerberos", + "NameIDFEntity", + "NameIDFPersistent", + "NameIDFTransient" + ], + "type": "string" + }, + "NameIdPolicy": { + "properties": { + "allowCreate": { + "type": "boolean" + }, + "format": { + "$ref": "#/components/schemas/NameIDFormat" + }, + "spNameQualifier": { + "$ref": "#/components/schemas/XmlText" + } + }, + "required": [ + "format", + "allowCreate" + ], + "type": "object" + }, + "NewAssetToken": { + "properties": { + "token": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "NewClient": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/components/schemas/ClientCapability" + }, + "type": "array" + }, + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "cookie": { + "description": "The cookie label, i.e. the label used when logging in.", + "type": "string" + }, + "label": { + "type": "string" + }, + "lastkey": { + "$ref": "#/components/schemas/Prekey" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "password": { + "description": "The password of the authenticated user for verification. Note: Required for registration of the 2nd, 3rd, ... client.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "prekeys": { + "description": "Prekeys for other clients to establish OTR sessions.", + "items": { + "$ref": "#/components/schemas/Prekey" + }, + "type": "array" + }, + "type": { + "$ref": "#/components/schemas/ClientType" + }, + "verification_code": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "prekeys", + "lastkey", + "type" + ], + "type": "object" + }, + "NewConv": { + "description": "JSON object to create a new conversation. When using 'qualified_users' (preferred), you can omit 'users'", + "properties": { + "access": { + "items": { + "$ref": "#/components/schemas/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/components/schemas/AccessRole" + }, + "type": "array" + }, + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "message_timer": { + "description": "Per-conversation message timer", + "format": "int64", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "protocol": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "qualified_users": { + "description": "List of qualified user IDs (excluding the requestor) to be part of this conversation", + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/components/schemas/ConvTeamInfo" + }, + "users": { + "deprecated": true, + "description": "List of user IDs (excluding the requestor) to be part of this conversation (deprecated)", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + } + }, + "type": "object" + }, + "NewLegalHoldService": { + "properties": { + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "public_key": { + "$ref": "#/components/schemas/ServiceKeyPEM" + } + }, + "required": [ + "base_url", + "public_key", + "auth_token" + ], + "type": "object" + }, + "NewPasswordReset": { + "description": "Data to initiate a password reset", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "phone": { + "description": "Email", + "type": "string" + } + }, + "type": "object" + }, + "NewProvider": { + "properties": { + "description": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "name", + "email", + "url", + "description" + ], + "type": "object" + }, + "NewProviderResponse": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "NewService": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "description": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "public_key": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "summary": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "name", + "summary", + "description", + "base_url", + "public_key", + "assets", + "tags" + ], + "type": "object" + }, + "NewServiceResponse": { + "properties": { + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "NewTeamMember": { + "description": "Required data when creating new team members", + "properties": { + "member": { + "description": "the team member to add (the legalhold_status field must be null or missing!)", + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "permissions": { + "$ref": "#/components/schemas/Permissions" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "permissions" + ], + "type": "object" + } + }, + "required": [ + "member" + ], + "type": "object" + }, + "NewUser": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "email_code": { + "$ref": "#/components/schemas/ASCII" + }, + "expires_in": { + "maximum": 604800, + "minimum": 1, + "type": "integer" + }, + "invitation_code": { + "$ref": "#/components/schemas/ASCII" + }, + "label": { + "type": "string" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "managed_by": { + "$ref": "#/components/schemas/ManagedBy" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "sso_id": { + "$ref": "#/components/schemas/UserSSOId" + }, + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/components/schemas/BindingNewTeamUser" + }, + "team_code": { + "$ref": "#/components/schemas/ASCII" + }, + "team_id": { + "$ref": "#/components/schemas/UUID" + }, + "uuid": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "OAuthAccessTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "code": { + "$ref": "#/components/schemas/OAuthAuthorizationCode" + }, + "code_verifier": { + "description": "The code verifier to complete the code challenge", + "maxLength": 128, + "minLength": 43, + "type": "string" + }, + "grant_type": { + "$ref": "#/components/schemas/OAuthGrantType" + }, + "redirect_uri": { + "$ref": "#/components/schemas/RedirectUrl" + } + }, + "required": [ + "grant_type", + "client_id", + "code_verifier", + "code", + "redirect_uri" + ], + "type": "object" + }, + "OAuthAccessTokenResponse": { + "properties": { + "access_token": { + "description": "The access token, which has a relatively short lifetime", + "type": "string" + }, + "expires_in": { + "description": "The lifetime of the access token in seconds", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "refresh_token": { + "description": "The refresh token, which has a relatively long lifetime, and can be used to obtain a new access token", + "type": "string" + }, + "token_type": { + "$ref": "#/components/schemas/OAuthAccessTokenType" + } + }, + "required": [ + "access_token", + "token_type", + "expires_in", + "refresh_token" + ], + "type": "object" + }, + "OAuthAccessTokenType": { + "description": "The type of the access token. Currently only `Bearer` is supported.", + "enum": [ + "Bearer" + ], + "type": "string" + }, + "OAuthApplication": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "description": "The OAuth client's name", + "maxLength": 256, + "minLength": 6, + "type": "string" + }, + "sessions": { + "description": "The OAuth client's sessions", + "items": { + "$ref": "#/components/schemas/OAuthSession" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "sessions" + ], + "type": "object" + }, + "OAuthAuthorizationCode": { + "description": "The authorization code", + "type": "string" + }, + "OAuthClient": { + "properties": { + "application_name": { + "maxLength": 256, + "minLength": 6, + "type": "string" + }, + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "redirect_url": { + "$ref": "#/components/schemas/RedirectUrl" + } + }, + "required": [ + "client_id", + "application_name", + "redirect_url" + ], + "type": "object" + }, + "OAuthCodeChallenge": { + "description": "Generated by the client from the code verifier (unpadded base64url-encoded SHA256 hash of the code verifier)", + "type": "string" + }, + "OAuthGrantType": { + "description": "Indicates which authorization flow to use. Use `authorization_code` for authorization code flow.", + "enum": [ + "authorization_code", + "refresh_token" + ], + "type": "string" + }, + "OAuthRefreshAccessTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "grant_type": { + "$ref": "#/components/schemas/OAuthGrantType" + }, + "refresh_token": { + "description": "The refresh token", + "type": "string" + } + }, + "required": [ + "grant_type", + "client_id", + "refresh_token" + ], + "type": "object" + }, + "OAuthResponseType": { + "description": "Indicates which authorization flow to use. Use `code` for authorization code flow.", + "enum": [ + "code" + ], + "type": "string" + }, + "OAuthRevokeRefreshTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/components/schemas/UUID" + }, + "refresh_token": { + "description": "The refresh token", + "type": "string" + } + }, + "required": [ + "client_id", + "refresh_token" + ], + "type": "object" + }, + "OAuthSession": { + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "refresh_token_id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "refresh_token_id", + "created_at" + ], + "type": "object" + }, + "Object": { + "additionalProperties": true, + "description": "A single notification event", + "properties": { + "type": { + "description": "Event type", + "type": "string" + } + }, + "title": "Event", + "type": "object" + }, + "OtherMember": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "status": { + "deprecated": true, + "description": "deprecated", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "OtherMemberUpdate": { + "description": "Update user properties of other members relative to a conversation", + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + } + }, + "type": "object" + }, + "OtrMessage": { + "description": "Encrypted message of a conversation", + "properties": { + "data": { + "description": "Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.", + "type": "string" + }, + "recipient": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "sender": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "text": { + "description": "The ciphertext for the recipient (Base64 in JSON)", + "type": "string" + } + }, + "required": [ + "sender", + "recipient", + "text" + ], + "type": "object" + }, + "OutlookCalIntegrationConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "OutlookCalIntegrationConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "OwnKeyPackages": { + "properties": { + "count": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "count" + ], + "type": "object" + }, + "PagingState": { + "description": "Paging state that should be supplied to retrieve the next page of results", + "type": "string" + }, + "PasswordChange": { + "properties": { + "new_password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "old_password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "old_password", + "new_password" + ], + "type": "object" + }, + "PasswordReqBody": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "PasswordReset": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "Permissions": { + "description": "This is just a complicated way of representing a team role. self and copy always have to contain the same integer, and only the following integers are allowed: 1025 (partner), 1587 (member), 5951 (admin), 8191 (owner). Unit tests of the galley-types package in wire-server contain an authoritative list.", + "properties": { + "copy": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "self": { + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "self", + "copy" + ], + "type": "object" + }, + "PhoneNumber": { + "description": "A known phone number with a pending password reset.", + "type": "string" + }, + "Pict": { + "items": { + "type": "object" + }, + "maxItems": 10, + "minItems": 0, + "type": "array" + }, + "Prekey": { + "properties": { + "id": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "key": { + "type": "string" + } + }, + "required": [ + "id", + "key" + ], + "type": "object" + }, + "PrekeyBundle": { + "properties": { + "clients": { + "items": { + "$ref": "#/components/schemas/ClientPrekey" + }, + "type": "array" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "clients" + ], + "type": "object" + }, + "Priority": { + "enum": [ + "low", + "high" + ], + "type": "string" + }, + "PropertyKeysAndValues": { + "type": "object" + }, + "PropertyValue": { + "description": "An arbitrary JSON value for a property" + }, + "Protocol": { + "enum": [ + "proteus", + "mls", + "mixed" + ], + "type": "string" + }, + "ProtocolUpdate": { + "properties": { + "protocol": { + "$ref": "#/components/schemas/Protocol" + } + }, + "type": "object" + }, + "Provider": { + "properties": { + "description": { + "type": "string" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "required": [ + "id", + "name", + "email", + "url", + "description" + ], + "type": "object" + }, + "ProviderActivationResponse": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "ProviderLogin": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "type": "object" + }, + "PubClient": { + "properties": { + "class": { + "$ref": "#/components/schemas/ClientClass" + }, + "id": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "PublicSubConversation": { + "description": "An MLS subconversation", + "properties": { + "cipher_suite": { + "$ref": "#/components/schemas/CipherSuiteTag" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/components/schemas/UTCTime" + }, + "group_id": { + "$ref": "#/components/schemas/GroupId" + }, + "members": { + "items": { + "$ref": "#/components/schemas/ClientIdentity" + }, + "type": "array" + }, + "parent_qualified_id": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "subconv_id": { + "type": "string" + } + }, + "required": [ + "parent_qualified_id", + "subconv_id", + "group_id", + "epoch", + "members" + ], + "type": "object" + }, + "PushToken": { + "description": "Native Push Token", + "properties": { + "app": { + "description": "Application", + "type": "string" + }, + "client": { + "description": "Client ID", + "type": "string" + }, + "token": { + "description": "Access Token", + "type": "string" + }, + "transport": { + "$ref": "#/components/schemas/Transport" + } + }, + "required": [ + "transport", + "app", + "token", + "client" + ], + "type": "object" + }, + "PushTokenList": { + "description": "List of Native Push Tokens", + "properties": { + "tokens": { + "description": "Push tokens", + "items": { + "$ref": "#/components/schemas/PushToken" + }, + "type": "array" + } + }, + "required": [ + "tokens" + ], + "type": "object" + }, + "QualifiedNewOtrMessage": { + "description": "This object can only be parsed from Protobuf.\nThe specification for the protobuf types is here: \nhttps://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto." + }, + "QualifiedUserClientPrekeyMapV4": { + "properties": { + "failed_to_list": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "qualified_user_client_prekeys": { + "additionalProperties": { + "$ref": "#/components/schemas/UserClientPrekeyMap" + }, + "type": "object" + } + }, + "required": [ + "qualified_user_client_prekeys" + ], + "type": "object" + }, + "QualifiedUserClients": { + "additionalProperties": { + "additionalProperties": { + "items": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + "description": "Map of Domain to UserClients", + "example": { + "domain1.example.com": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + "60f85e4b15ad3786", + "6e323ab31554353b" + ] + } + }, + "type": "object" + }, + "QualifiedUserIdList with EdMemberLeftReason": { + "properties": { + "qualified_user_ids": { + "items": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "type": "array" + }, + "reason": { + "$ref": "#/components/schemas/EdMemberLeftReason" + }, + "user_ids": { + "deprecated": true, + "description": "Deprecated, use qualified_user_ids", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + } + }, + "required": [ + "reason", + "qualified_user_ids", + "user_ids" + ], + "type": "object" + }, + "QualifiedUserMap_Set_PubClient": { + "additionalProperties": { + "$ref": "#/components/schemas/UserMap_Set_PubClient" + }, + "description": "Map of Domain to (UserMap (Set_PubClient)).", + "example": { + "domain1.example.com": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + { + "class": "legalhold", + "id": "d0" + } + ] + } + }, + "type": "object" + }, + "Qualified_ConvId": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "id" + ], + "type": "object" + }, + "Qualified_Handle": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + } + }, + "required": [ + "domain", + "handle" + ], + "type": "object" + }, + "Qualified_UserId": { + "properties": { + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "domain", + "id" + ], + "type": "object" + }, + "QueuedNotification": { + "description": "A single notification", + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "payload": { + "description": "List of events", + "items": { + "$ref": "#/components/schemas/Object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "id", + "payload" + ], + "type": "object" + }, + "QueuedNotificationList": { + "description": "Zero or more notifications", + "properties": { + "has_more": { + "description": "Whether there are still more notifications.", + "type": "boolean" + }, + "notifications": { + "description": "Notifications", + "items": { + "$ref": "#/components/schemas/QueuedNotification" + }, + "type": "array" + }, + "time": { + "$ref": "#/components/schemas/UTCTime" + } + }, + "required": [ + "notifications" + ], + "type": "object" + }, + "RTCConfiguration": { + "description": "A subset of the WebRTC 'RTCConfiguration' dictionary", + "properties": { + "ice_servers": { + "description": "Array of 'RTCIceServer' objects", + "items": { + "$ref": "#/components/schemas/RTCIceServer" + }, + "minItems": 1, + "type": "array" + }, + "is_federating": { + "description": "True if the client should connect to an SFT in the sft_servers_all and request it to federate", + "type": "boolean" + }, + "sft_servers": { + "description": "Array of 'SFTServer' objects (optional)", + "items": { + "$ref": "#/components/schemas/SftServer" + }, + "minItems": 1, + "type": "array" + }, + "sft_servers_all": { + "description": "Array of all SFT servers", + "items": { + "$ref": "#/components/schemas/SftServer" + }, + "type": "array" + }, + "ttl": { + "description": "Number of seconds after which the configuration should be refreshed (advisory)", + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "ice_servers", + "ttl" + ], + "type": "object" + }, + "RTCIceServer": { + "description": "A subset of the WebRTC 'RTCIceServer' object", + "properties": { + "credential": { + "$ref": "#/components/schemas/ASCII" + }, + "urls": { + "description": "Array of TURN server addresses of the form 'turn::'", + "items": { + "$ref": "#/components/schemas/TurnURI" + }, + "minItems": 1, + "type": "array" + }, + "username": { + "$ref": "#/components/schemas/" + } + }, + "required": [ + "urls", + "username", + "credential" + ], + "type": "object" + }, + "RedirectUrl": { + "description": "The URL must match the URL that was used to generate the authorization code.", + "type": "string" + }, + "Relation": { + "enum": [ + "accepted", + "blocked", + "pending", + "ignored", + "sent", + "cancelled", + "missing-legalhold-consent" + ], + "type": "string" + }, + "RemoveBotResponse": { + "properties": { + "event": { + "$ref": "#/components/schemas/Event" + } + }, + "required": [ + "event" + ], + "type": "object" + }, + "RemoveCookies": { + "description": "Data required to remove cookies", + "properties": { + "ids": { + "description": "A list of cookie IDs to revoke", + "items": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "labels": { + "description": "A list of cookie labels for which to revoke the cookies", + "items": { + "type": "string" + }, + "type": "array" + }, + "password": { + "description": "The user's password", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "RemoveLegalHoldSettingsRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "RichField": { + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "RichInfoAssocList": { + "description": "json object with case-insensitive fields.", + "properties": { + "fields": { + "items": { + "$ref": "#/components/schemas/RichField" + }, + "type": "array" + }, + "version": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "version", + "fields" + ], + "type": "object" + }, + "Role": { + "description": "Role of the invited user", + "enum": [ + "owner", + "admin", + "member", + "partner" + ], + "type": "string" + }, + "RoleName": { + "description": "Role name, between 2 and 128 chars, 'wire_' prefix is reserved for roles designed by Wire (i.e., no custom roles can have the same prefix)", + "type": "string" + }, + "SSOConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "ScimTokenInfo": { + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTime" + }, + "description": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "idp": { + "$ref": "#/components/schemas/UUID" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "team", + "id", + "created_at", + "description" + ], + "type": "object" + }, + "ScimTokenList": { + "properties": { + "tokens": { + "items": { + "$ref": "#/components/schemas/ScimTokenInfo" + }, + "type": "array" + } + }, + "required": [ + "tokens" + ], + "type": "object" + }, + "SearchResult": { + "properties": { + "documents": { + "description": "List of contacts found", + "items": { + "$ref": "#/components/schemas/TeamContact" + }, + "type": "array" + }, + "found": { + "description": "Total number of hits", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "has_more": { + "description": "Indicates whether there are more results to be fetched", + "type": "boolean" + }, + "paging_state": { + "$ref": "#/components/schemas/PagingState" + }, + "returned": { + "description": "Total number of hits returned", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "search_policy": { + "$ref": "#/components/schemas/FederatedUserSearchPolicy" + }, + "took": { + "description": "Search time in ms", + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + } + }, + "required": [ + "found", + "returned", + "took", + "documents", + "search_policy" + ], + "type": "object" + }, + "SearchVisibilityAvailableConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SearchVisibilityAvailableConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SearchVisibilityInboundConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SearchVisibilityInboundConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig": { + "properties": { + "enforcedTimeoutSeconds": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "enforcedTimeoutSeconds" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig.Feature": { + "properties": { + "config": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig.LockableFeature": { + "properties": { + "config": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig" + }, + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "SendActivationCode": { + "description": "Data for requesting an email code to be sent. 'email' must be present.", + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "SendVerificationCode": { + "properties": { + "action": { + "$ref": "#/components/schemas/VerificationAction" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": [ + "action", + "email" + ], + "type": "object" + }, + "Service": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "auth_tokens": { + "$ref": "#/components/schemas/List1" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "public_keys": { + "$ref": "#/components/schemas/List1" + }, + "summary": { + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "summary", + "description", + "base_url", + "auth_tokens", + "public_keys", + "assets", + "tags", + "enabled" + ], + "type": "object" + }, + "ServiceKey": { + "properties": { + "pem": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "size": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "type": { + "$ref": "#/components/schemas/ServiceKeyType" + } + }, + "required": [ + "type", + "size", + "pem" + ], + "type": "object" + }, + "ServiceKeyPEM": { + "example": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0\nG06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH\nWvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV\nVPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS\nbUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8\n7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la\nnQIDAQAB\n-----END PUBLIC KEY-----\n", + "type": "string" + }, + "ServiceKeyType": { + "enum": [ + "rsa" + ], + "type": "string" + }, + "ServiceProfile": { + "properties": { + "has_more": { + "type": "boolean" + }, + "services": { + "items": { + "$ref": "#/components/schemas/ServiceProfile" + }, + "type": "array" + } + }, + "required": [ + "has_more", + "services" + ], + "type": "object" + }, + "ServiceRef": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "provider": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id", + "provider" + ], + "type": "object" + }, + "ServiceTagList": { + "items": { + "$ref": "#/components/schemas/" + }, + "type": "array" + }, + "SftServer": { + "description": "Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers", + "properties": { + "urls": { + "description": "Array containing exactly one SFT server address of the form 'https://:'", + "items": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "type": "array" + } + }, + "required": [ + "urls" + ], + "type": "object" + }, + "SimpleMember": { + "properties": { + "conversation_role": { + "$ref": "#/components/schemas/RoleName" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + } + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "SimpleMembers": { + "properties": { + "user_ids": { + "deprecated": true, + "description": "deprecated", + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "users": { + "items": { + "$ref": "#/components/schemas/SimpleMember" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + }, + "SndFactorPasswordChallengeConfig.Feature": { + "properties": { + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SndFactorPasswordChallengeConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SomeKey": {}, + "Sso": { + "properties": { + "issuer": { + "type": "string" + }, + "nameid": { + "type": "string" + } + }, + "required": [ + "issuer", + "nameid" + ], + "type": "object" + }, + "SsoSettings": { + "properties": { + "default_sso_code": { + "$ref": "#/components/schemas/UUID" + } + }, + "type": "object" + }, + "SupportedProtocolUpdate": { + "properties": { + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + } + }, + "required": [ + "supported_protocols" + ], + "type": "object" + }, + "SystemSettings": { + "properties": { + "setEnableMls": { + "description": "Whether MLS is enabled or not", + "type": "boolean" + }, + "setRestrictUserCreation": { + "description": "Do not allow certain user creation flows", + "type": "boolean" + } + }, + "required": [ + "setRestrictUserCreation", + "setEnableMls" + ], + "type": "object" + }, + "SystemSettingsPublic": { + "properties": { + "setRestrictUserCreation": { + "description": "Do not allow certain user creation flows", + "type": "boolean" + } + }, + "required": [ + "setRestrictUserCreation" + ], + "type": "object" + }, + "Team": { + "description": "`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`.", + "properties": { + "binding": { + "$ref": "#/components/schemas/TeamBinding" + }, + "creator": { + "$ref": "#/components/schemas/UUID" + }, + "icon": { + "$ref": "#/components/schemas/Icon" + }, + "icon_key": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "name": { + "type": "string" + }, + "splash_screen": { + "$ref": "#/components/schemas/Icon" + } + }, + "required": [ + "id", + "creator", + "name", + "icon" + ], + "type": "object" + }, + "TeamBinding": { + "deprecated": true, + "description": "Deprecated, please ignore.", + "enum": [ + true, + false + ], + "type": "boolean" + }, + "TeamContact": { + "properties": { + "accent_id": { + "maximum": 9223372036854775807, + "minimum": -9223372036854775808, + "type": "integer" + }, + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "email_unvalidated": { + "$ref": "#/components/schemas/Email" + }, + "handle": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "managed_by": { + "$ref": "#/components/schemas/ManagedBy" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/Role" + }, + "saml_idp": { + "type": "string" + }, + "scim_external_id": { + "type": "string" + }, + "sso": { + "$ref": "#/components/schemas/Sso" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "TeamConversation": { + "description": "Team conversation data", + "properties": { + "conversation": { + "$ref": "#/components/schemas/UUID" + }, + "managed": { + "description": "This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface." + } + }, + "required": [ + "conversation", + "managed" + ], + "type": "object" + }, + "TeamConversationList": { + "description": "Team conversation list", + "properties": { + "conversations": { + "items": { + "$ref": "#/components/schemas/TeamConversation" + }, + "type": "array" + } + }, + "required": [ + "conversations" + ], + "type": "object" + }, + "TeamDeleteData": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "verification_code": { + "$ref": "#/components/schemas/ASCII" + } + }, + "type": "object" + }, + "TeamMember": { + "description": "team member data", + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "legalhold_status": { + "$ref": "#/components/schemas/UserLegalHoldStatus" + }, + "permissions": { + "$ref": "#/components/schemas/Permissions" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user" + ], + "type": "object" + }, + "TeamMemberDeleteData": { + "description": "Data for a team member deletion request in case of binding teams.", + "properties": { + "password": { + "description": "The account password to authorise the deletion.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "TeamMemberList": { + "description": "list of team member", + "properties": { + "hasMore": { + "$ref": "#/components/schemas/ListType" + }, + "members": { + "description": "the array of team members", + "items": { + "$ref": "#/components/schemas/TeamMember" + }, + "type": "array" + } + }, + "required": [ + "members", + "hasMore" + ], + "type": "object" + }, + "TeamMembersPage": { + "properties": { + "hasMore": { + "type": "boolean" + }, + "members": { + "items": { + "$ref": "#/components/schemas/TeamMember" + }, + "type": "array" + }, + "pagingState": { + "$ref": "#/components/schemas/TeamMembers_PagingState" + } + }, + "required": [ + "members", + "hasMore", + "pagingState" + ], + "type": "object" + }, + "TeamMembers_PagingState": { + "type": "string" + }, + "TeamSearchVisibility": { + "description": "value of visibility", + "enum": [ + "standard", + "no-name-outside-team" + ], + "type": "string" + }, + "TeamSearchVisibilityView": { + "description": "Search visibility value for the team", + "properties": { + "search_visibility": { + "$ref": "#/components/schemas/TeamSearchVisibility" + } + }, + "required": [ + "search_visibility" + ], + "type": "object" + }, + "TeamSize": { + "description": "A simple object with a total number of team members.", + "properties": { + "teamSize": { + "description": "Team size.", + "exclusiveMinimum": false, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "teamSize" + ], + "type": "object" + }, + "TeamUpdateData": { + "properties": { + "icon": { + "$ref": "#/components/schemas/Icon" + }, + "icon_key": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "splash_screen": { + "$ref": "#/components/schemas/Icon" + } + }, + "type": "object" + }, + "Time": { + "properties": { + "time": { + "$ref": "#/components/schemas/UTCTime" + } + }, + "required": [ + "time" + ], + "type": "object" + }, + "TokenType": { + "enum": [ + "Bearer" + ], + "type": "string" + }, + "Transport": { + "description": "Transport", + "enum": [ + "GCM", + "APNS", + "APNS_SANDBOX", + "APNS_VOIP", + "APNS_VOIP_SANDBOX" + ], + "type": "string" + }, + "TurnURI": { + "type": "string" + }, + "TypingData": { + "properties": { + "status": { + "$ref": "#/components/schemas/TypingStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "TypingStatus": { + "enum": [ + "started", + "stopped" + ], + "type": "string" + }, + "URIRef Absolute": { + "description": "URL of the invitation link to be sent to the invitee", + "type": "string" + }, + "UTCTime": { + "example": "2021-05-12T10:52:02Z", + "format": "yyyy-mm-ddThh:MM:ssZ", + "type": "string" + }, + "UTCTimeMillis": { + "description": "The time when the session was created", + "example": "2021-05-12T10:52:02.671Z", + "format": "yyyy-mm-ddThh:MM:ss.qqqZ", + "type": "string" + }, + "UUID": { + "description": "The OAuth client's ID", + "example": "99db9768-04e3-4b5d-9268-831b6a25c4ab", + "format": "uuid", + "type": "string" + }, + "Unnamed": { + "properties": { + "created_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "created_by": { + "$ref": "#/components/schemas/UUID" + }, + "permissions": { + "$ref": "#/components/schemas/Permissions" + }, + "user": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "user", + "permissions" + ], + "type": "object" + }, + "UpdateBotPrekeys": { + "properties": { + "prekeys": { + "items": { + "$ref": "#/components/schemas/Prekey" + }, + "type": "array" + } + }, + "required": [ + "prekeys" + ], + "type": "object" + }, + "UpdateClient": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/components/schemas/ClientCapability" + }, + "type": "array" + }, + "label": { + "description": "A new name for this client.", + "type": "string" + }, + "lastkey": { + "$ref": "#/components/schemas/Prekey" + }, + "mls_public_keys": { + "$ref": "#/components/schemas/MLSPublicKeys" + }, + "prekeys": { + "description": "New prekeys for other clients to establish OTR sessions.", + "items": { + "$ref": "#/components/schemas/Prekey" + }, + "type": "array" + } + }, + "type": "object" + }, + "UpdateProvider": { + "properties": { + "description": { + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "url": { + "$ref": "#/components/schemas/HttpsUrl" + } + }, + "type": "object" + }, + "UpdateService": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "description": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "summary": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/" + }, + "maxItems": 3, + "minItems": 1, + "type": "array" + } + }, + "type": "object" + }, + "UpdateServiceConn": { + "properties": { + "auth_tokens": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "enabled": { + "type": "boolean" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "public_keys": { + "items": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "maxItems": 2, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "UpdateServiceWhitelist": { + "properties": { + "id": { + "$ref": "#/components/schemas/UUID" + }, + "provider": { + "$ref": "#/components/schemas/UUID" + }, + "whitelisted": { + "type": "boolean" + } + }, + "required": [ + "provider", + "id", + "whitelisted" + ], + "type": "object" + }, + "User": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "expires_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "locale": { + "$ref": "#/components/schemas/Locale" + }, + "managed_by": { + "$ref": "#/components/schemas/ManagedBy" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "sso_id": { + "$ref": "#/components/schemas/UserSSOId" + }, + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "text_status": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "qualified_id", + "name", + "accent_id", + "locale" + ], + "type": "object" + }, + "UserAsset": { + "properties": { + "key": { + "$ref": "#/components/schemas/AssetKey" + }, + "size": { + "$ref": "#/components/schemas/AssetSize" + }, + "type": { + "$ref": "#/components/schemas/AssetType" + } + }, + "required": [ + "key", + "type" + ], + "type": "object" + }, + "UserClientMap": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object" + }, + "UserClientPrekeyMap": { + "additionalProperties": { + "additionalProperties": { + "properties": { + "id": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "key": { + "type": "string" + } + }, + "required": [ + "id", + "key" + ], + "type": "object" + }, + "type": "object" + }, + "example": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": { + "44901fb0712e588f": { + "id": 1, + "key": "pQABAQECoQBYIOjl7hw0D8YRNq..." + } + } + }, + "type": "object" + }, + "UserClients": { + "additionalProperties": { + "items": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "type": "array" + }, + "description": "Map of user id to list of client ids.", + "example": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + "60f85e4b15ad3786", + "6e323ab31554353b" + ] + }, + "type": "object" + }, + "UserConnection": { + "properties": { + "conversation": { + "$ref": "#/components/schemas/UUID" + }, + "from": { + "$ref": "#/components/schemas/UUID" + }, + "last_update": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "qualified_conversation": { + "$ref": "#/components/schemas/Qualified_ConvId" + }, + "qualified_to": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "status": { + "$ref": "#/components/schemas/Relation" + }, + "to": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "from", + "qualified_to", + "status", + "last_update" + ], + "type": "object" + }, + "UserIdList": { + "properties": { + "user_ids": { + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + } + }, + "required": [ + "user_ids" + ], + "type": "object" + }, + "UserLegalHoldStatus": { + "description": "The state of Legal Hold compliance for the member", + "enum": [ + "enabled", + "pending", + "disabled", + "no_consent" + ], + "type": "string" + }, + "UserLegalHoldStatusResponse": { + "properties": { + "client": { + "$ref": "#/components/schemas/Id" + }, + "last_prekey": { + "$ref": "#/components/schemas/Prekey" + }, + "status": { + "$ref": "#/components/schemas/UserLegalHoldStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "UserMap_Set_PubClient": { + "additionalProperties": { + "items": { + "$ref": "#/components/schemas/PubClient" + }, + "type": "array", + "uniqueItems": true + }, + "description": "Map of UserId to (Set PubClient)", + "example": { + "1d51e2d6-9c70-605f-efc8-ff85c3dabdc7": [ + { + "class": "legalhold", + "id": "d0" + } + ] + }, + "type": "object" + }, + "UserProfile": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "$ref": "#/components/schemas/Email" + }, + "expires_at": { + "$ref": "#/components/schemas/UTCTimeMillis" + }, + "handle": { + "$ref": "#/components/schemas/Handle" + }, + "id": { + "$ref": "#/components/schemas/UUID" + }, + "legalhold_status": { + "$ref": "#/components/schemas/UserLegalHoldStatus" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "qualified_id": { + "$ref": "#/components/schemas/Qualified_UserId" + }, + "service": { + "$ref": "#/components/schemas/ServiceRef" + }, + "supported_protocols": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/components/schemas/UUID" + }, + "text_status": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "qualified_id", + "name", + "accent_id", + "legalhold_status" + ], + "type": "object" + }, + "UserSSOId": { + "properties": { + "scim_external_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "tenant": { + "type": "string" + } + }, + "type": "object" + }, + "UserUpdate": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/components/schemas/UserAsset" + }, + "type": "array" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/components/schemas/Pict" + }, + "text_status": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "ValidateSAMLEmailsConfig.LockableFeature": { + "properties": { + "lockStatus": { + "$ref": "#/components/schemas/LockStatus" + }, + "status": { + "$ref": "#/components/schemas/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "VerificationAction": { + "enum": [ + "create_scim_token", + "login", + "delete_team" + ], + "type": "string" + }, + "VerifyDeleteUser": { + "description": "Data for verifying an account deletion.", + "properties": { + "code": { + "$ref": "#/components/schemas/ASCII" + }, + "key": { + "$ref": "#/components/schemas/ASCII" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "VersionInfo": { + "example": { + "development": [ + 7 + ], + "domain": "example.com", + "federation": false, + "supported": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ] + }, + "properties": { + "development": { + "items": { + "$ref": "#/components/schemas/VersionNumber" + }, + "type": "array" + }, + "domain": { + "$ref": "#/components/schemas/Domain" + }, + "federation": { + "type": "boolean" + }, + "supported": { + "items": { + "$ref": "#/components/schemas/VersionNumber" + }, + "type": "array" + } + }, + "required": [ + "supported", + "development", + "federation", + "domain" + ], + "type": "object" + }, + "VersionNumber": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "type": "integer" + }, + "ViewLegalHoldService": { + "properties": { + "settings": { + "$ref": "#/components/schemas/ViewLegalHoldServiceInfo" + }, + "status": { + "$ref": "#/components/schemas/LHServiceStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ViewLegalHoldServiceInfo": { + "properties": { + "auth_token": { + "$ref": "#/components/schemas/ASCII" + }, + "base_url": { + "$ref": "#/components/schemas/HttpsUrl" + }, + "fingerprint": { + "$ref": "#/components/schemas/Fingerprint" + }, + "public_key": { + "$ref": "#/components/schemas/ServiceKeyPEM" + }, + "team_id": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "team_id", + "base_url", + "fingerprint", + "auth_token", + "public_key" + ], + "type": "object" + }, + "WireIdP": { + "properties": { + "apiVersion": { + "$ref": "#/components/schemas/WireIdPAPIVersion" + }, + "handle": { + "type": "string" + }, + "oldIssuers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "replacedBy": { + "$ref": "#/components/schemas/UUID" + }, + "team": { + "$ref": "#/components/schemas/UUID" + } + }, + "required": [ + "team", + "oldIssuers", + "handle" + ], + "type": "object" + }, + "WireIdPAPIVersion": { + "enum": [ + "WireIdPAPIV1", + "WireIdPAPIV2" + ], + "type": "string" + }, + "XmlText": { + "properties": { + "fromXmlText": { + "type": "string" + } + }, + "required": [ + "fromXmlText" + ], + "type": "object" + }, + "new-otr-message": { + "properties": { + "data": { + "type": "string" + }, + "native_priority": { + "$ref": "#/components/schemas/Priority" + }, + "native_push": { + "type": "boolean" + }, + "recipients": { + "$ref": "#/components/schemas/UserClientMap" + }, + "report_missing": { + "items": { + "$ref": "#/components/schemas/UUID" + }, + "type": "array" + }, + "sender": { + "description": "A 64-bit unsigned integer, represented as a hexadecimal numeral. Any valid hexadecimal numeral is accepted, but the backend will only produce representations with lowercase digits and no leading zeros", + "type": "string" + }, + "transient": { + "type": "boolean" + } + }, + "required": [ + "sender", + "recipients" + ], + "type": "object" + } + }, + "securitySchemes": { + "ZAuth": { + "description": "Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'.", + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + } + }, + "info": { + "description": "## Authentication / Authorization\n\nThe end-points in this API support differing authorization protocols:\nsome are unauthenticated (`/api-version`, `/login`), some require\n[zauth](), and some support both [zauth]() and [oauth]().\n\nThe end-points that require zauth are labelled so in the description\nbelow. The end-points that support oauth as an alternative to zauth\nhave the required oauth scopes listed in the same description.\n\nFuther reading:\n- https://docs.wire.com/developer/reference/oauth.html\n- https://github.com/wireapp/wire-server/blob/develop/libs/wire-api/src/Wire/API/Routes/Public.hs (search for HasSwagger instances)\n- `curl https://staging-nginz-https.zinfra.io/v4/api/swagger.json | jq '.security, .securityDefinitions`\n\n### SSO Endpoints\n\n#### Overview\n\n`/sso/metadata` will be requested by the IdPs to learn how to talk to wire.\n\n`/sso/initiate-login`, `/sso/finalize-login` are for the SAML authentication handshake performed by a user in order to log into wire. They are not exactly standard in their details: they may return HTML or XML; redirect to error URLs instead of throwing errors, etc.\n\n`/identity-providers` end-points are for use in the team settings page when IdPs are registered. They talk json.\n\n\n#### Configuring IdPs\n\nIdPs usually allow you to copy the metadata into your clipboard. That should contain all the details you need to post the idp in your team under `/identity-providers`. (Team id is derived from the authorization credentials of the request.)\n\n##### okta.com\n\nOkta will ask you to provide two URLs when you set it up for talking to wireapp:\n\n1. The `Single sign on URL`. This is the end-point that accepts the user's credentials after successful authentication against the IdP. Choose `/sso/finalize-login` with schema and hostname of the wire server you are configuring.\n\n2. The `Audience URI`. You can find this in the metadata returned by the `/sso/metadata` end-point. It is the contents of the `md:OrganizationURL` element.\n\n##### centrify.com\n\nCentrify allows you to upload the metadata xml document that you get from the `/sso/metadata` end-point. You can also enter the metadata url and have centrify retrieve the xml, but to guarantee integrity of the setup, the metadata should be copied from the team settings page and pasted into the centrify setup page without any URL indirections.\n\n## Federation errors\n\nEndpoints involving federated calls to other domains can return some extra failure responses, common to all endpoints. Instead of listing them as possible responses for each endpoint, we document them here.\n\nFor errors that are more likely to be transient, we suggest clients to retry whatever request resulted in the error. Transient errors are indicated explicitly below.\n\n**Note**: when a failure occurs as a result of making a federated RPC to another backend, the error response contains the following extra fields:\n\n - `type`: \"federation\" (just the literal string in quotes, which can be used as an error type identifier when parsing errors)\n - `domain`: the target backend of the RPC that failed;\n - `path`: the path of the RPC that failed.\n\n### Domain errors\n\nErrors in this category result from trying to communicate with a backend that is considered non-existent or invalid. They can result from invalid user input or client issues, but they can also be a symptom of misconfiguration in one or multiple backends. These errors have a 4xx status code.\n\n - **Remote backend not found** (status: 422, label: `invalid-domain`): This backend attempted to contact a backend which does not exist or is not properly configured. For the most part, clients can consider this error equivalent to a domain not existing, although it should be noted that certain mistakes in the DNS configuration on a remote backend can lead to the backend not being recognized, and hence to this error. It is therefore not advisable to take any destructive action upon encountering this error, such as deleting remote users from conversations.\n - **Federation denied locally** (status: 400, label: `federation-denied`): This backend attempted an RPC to a non-whitelisted backend. Similar considerations as for the previous error apply.\n - **Federation not enabled** (status: 400, label: `federation-not-enabled`): Federation has not been configured for this backend. This will happen if a federation-aware client tries to talk to a backend for which federation is disabled, or if federation was disabled on the backend after reaching a federation-specific state (e.g. conversations with remote users). There is no way to cleanly recover from these errors at this point.\n\n### Local federation errors\n\nAn error in this category likely indicates an issue with the configuration of federation on the local backend. Possibly transient errors are indicated explicitly below. All these errors have a 500 status code.\n\n - **Federation unavailable** (status: 500, label: `federation-not-available`): Federation is configured for this backend, but the local federator cannot be reached. This can be transient, so clients should retry the request.\n - **Federation not implemented** (status: 500, label: `federation-not-implemented`): Federated behaviour for a certain endpoint is not yet implemented.\n - **Federator discovery failed** (status: 400, label: `discovery-failure`): A DNS error occurred during discovery of a remote backend. This can be transient, so clients should retry the request.\n - **Local federation error** (status: 500, label: `federation-local-error`): An error occurred in the communication between this backend and its local federator. These errors are most likely caused by bugs in the backend, and should be reported as such.\n\n### Remote federation errors\n\nErrors in this category are returned in case of communication issues between the local backend and a remote one, or if the remote side encountered an error while processing an RPC. Some errors in this category might be caused by incorrect client behaviour, wrong user input, or incorrect certificate configuration. Possibly transient errors are indicated explicitly. We use non-standard 5xx status codes for these errors.\n\n - **HTTP2 error** (status: 533, label: `federation-http2-error`): The current federator encountered an error when making an HTTP2 request to a remote one. Check the error message for more details.\n - **Connection refused** (status: 521, label: `federation-connection-refused`): The local federator could not connect to a remote one. This could be transient, so clients should retry the request.\n - **TLS failure**: (status: 525, label: `federation-tls-error`): An error occurred during the TLS handshake between the local federator and a remote one. This is most likely due to an issue with the certificate on the remote end.\n - **Remote federation error** (status: 533, label: `federation-remote-error`): The remote backend could not process a request coming from this backend. Check the error message for more details.\n - **Version negotiation error** (status: 533, label: `federation-version-error`): The remote backend returned invalid version information.\n\n### Backend compatibility errors\n\nAn error in this category will be returned when this backend makes an invalid or unsupported RPC to another backend. This can indicate some incompatibility between backends or a backend bug. These errors are unlikely to be transient, so retrying requests is *not* advised.\n\n - **Version mismatch** (status: 531, label: `federation-version-mismatch`): A remote backend is running an unsupported version of the federator.\n - **Invalid content type** (status: 533, label: `federation-invalid-content-type`): An RPC to another backend returned with an invalid content type.\n - **Unsupported content type** (status: 533, label: `federation-unsupported-content-type`): An RPC to another backend returned with an unsupported content type.\n", + "title": "Wire-Server API", + "version": "" + }, + "openapi": "3.0.0", + "paths": { + "/": { + "get": { + "description": " [internal route ID: \"get-services-tags\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceTagList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Get services tags" + } + }, + "/access": { + "post": { + "description": " [internal route ID: \"access\"]\n\nYou can provide only a cookie or a cookie and token. Every other combination is invalid. Access tokens can be given as query parameter or authorisation header, with the latter being preferred.Calls federation service brig on send-connection-action", + "parameters": [ + { + "in": "query", + "name": "client_id", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + } + }, + "description": "OK", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Obtain an access tokens for a cookie" + } + }, + "/access/logout": { + "post": { + "description": " [internal route ID: \"logout\"]\n\nCalling this endpoint will effectively revoke the given cookie and subsequent calls to /access with the same cookie will result in a 403.", + "responses": { + "200": { + "description": "Logout" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Log out in order to remove a cookie from the server" + } + }, + "/access/self/email": { + "put": { + "description": " [internal route ID: \"change-self-email\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EmailUpdate" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "Update accepted and pending activation of the new email" + }, + "204": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "No update, current and new email address are the same" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid e-mail address. (label: `invalid-email`) or `body`" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "blacklisted-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Change your email address" + } + }, + "/activate": { + "get": { + "description": " [internal route ID: \"get-activate\"]\n\nSee also 'POST /activate' which has a larger feature set.
Calls federation service brig on send-connection-action", + "parameters": [ + { + "description": "Activation key", + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Activation code", + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + } + }, + "description": "Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful." + }, + "204": { + "description": "A recent activation was already successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-phone", + "message": "Invalid mobile phone number" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-phone", + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `code` or `key`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "Invalid activation code" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Activate (i.e. confirm) an email address." + }, + "post": { + "description": " [internal route ID: \"post-activate\"]\n\nActivation only succeeds once and the number of failed attempts for a valid key is limited.Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Activate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ActivationResponse" + } + } + }, + "description": "Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful." + }, + "204": { + "description": "A recent activation was already successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-phone", + "message": "Invalid mobile phone number" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-phone", + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "Invalid activation code" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Activate (i.e. confirm) an email address." + } + }, + "/activate/send": { + "post": { + "description": " [internal route ID: \"post-activate-send\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SendActivationCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Activation code sent." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "blacklisted-email", + "message": "The given e-mail address has been blacklisted due to a permanent bounce or a complaint." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "blacklisted-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + }, + "451": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 451, + "label": "domain-blocked-for-registration", + "message": "[Customer extension] the email domain example.com that you are attempting to register a user with has been blocked for creating wire users. Please contact your IT department." + }, + "properties": { + "code": { + "enum": [ + 451 + ], + "type": "integer" + }, + "label": { + "enum": [ + "domain-blocked-for-registration" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "[Customer extension] the email domain example.com that you are attempting to register a user with has been blocked for creating wire users. Please contact your IT department. (label: `domain-blocked-for-registration`)" + } + }, + "summary": "Send (or resend) an email activation code." + } + }, + "/api-version": { + "get": { + "description": " [internal route ID: \"get-version\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/VersionInfo" + } + } + }, + "description": "" + } + } + } + }, + "/assets": { + "post": { + "requestBody": { + "content": { + "multipart/mixed": { + "schema": { + "$ref": "#/components/schemas/AssetSource" + } + } + }, + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server." + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + }, + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-length", + "message": "Invalid content length" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-length" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Asset too large (label: `client-error`)" + } + }, + "summary": "Upload an asset" + } + }, + "/assets/{key_domain}/{key}": { + "delete": { + "description": "**Note**: only local assets can be deleted.", + "parameters": [ + { + "in": "path", + "name": "key_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key_domain` or `key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Delete an asset" + }, + "get": { + "description": "**Note**: local assets result in a redirect, while remote assets are streamed directly.Calls federation service cargohold on stream-asset
Calls federation service cargohold on get-asset", + "parameters": [ + { + "in": "path", + "name": "key_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset returned directly with content type `application/octet-stream`" + }, + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key_domain` or `key` or Asset not found (label: `not-found`)\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Download an asset" + } + }, + "/assets/{key}/token": { + "delete": { + "description": "**Note**: deleting the token makes the asset public.", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset token deleted" + } + }, + "summary": "Delete an asset token" + }, + "post": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewAssetToken" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Renew an asset token" + } + }, + "/await": { + "get": { + "description": " [internal route ID: \"await-notifications\"]\n\n", + "externalDocs": { + "description": "RFC 6455", + "url": "https://datatracker.ietf.org/doc/html/rfc6455" + }, + "parameters": [ + { + "description": "Client ID", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "101": { + "description": "Connection upgraded." + }, + "426": { + "description": "Upgrade required." + } + }, + "summary": "Establish websocket connection" + } + }, + "/bot/assets": { + "post": { + "requestBody": { + "content": { + "multipart/mixed": { + "schema": { + "$ref": "#/components/schemas/AssetSource" + } + } + }, + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server." + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + }, + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-length", + "message": "Invalid content length" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-length" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Asset too large (label: `client-error`)" + } + }, + "summary": "Upload an asset" + } + }, + "/bot/assets/{key}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Delete an asset" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` or Asset not found (label: `not-found`)" + } + }, + "summary": "Download an asset" + } + }, + "/bot/client": { + "get": { + "description": " [internal route ID: \"bot-get-client-v6\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + } + }, + "description": "Client found" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Client not found (label: `client-not-found`)\n\nClient not found (label: `client-not-found`)" + } + }, + "summary": "Get client for bot" + } + }, + "/bot/client/prekeys": { + "get": { + "description": " [internal route ID: \"bot-list-prekeys\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List prekeys for bot" + }, + "post": { + "description": " [internal route ID: \"bot-update-prekeys\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateBotPrekeys" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Client not found (label: `client-not-found`)" + } + }, + "summary": "Update prekeys for bot" + } + }, + "/bot/conversation": { + "get": { + "description": " [internal route ID: \"get-bot-conversation\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/BotConvView" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)" + } + } + } + }, + "/bot/messages": { + "post": { + "description": " [internal route ID: \"post-bot-message-unqualified\"]\n\nCalls federation service brig on get-user-clients
Calls federation service galley on on-message-sent", + "parameters": [ + { + "in": "query", + "name": "ignore_missing", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Message sent" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation not found (label: `no-conversation`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Missing clients" + } + } + } + }, + "/bot/self": { + "delete": { + "description": " [internal route ID: \"bot-delete-self\"]\n\n", + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-bot", + "message": "The targeted user is not a bot." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-bot", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The targeted user is not a bot. (label: `invalid-bot`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Delete self" + }, + "get": { + "description": " [internal route ID: \"bot-get-self\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserProfile" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "User not found (label: `not-found`)" + } + }, + "summary": "Get self" + } + }, + "/bot/users": { + "get": { + "description": " [internal route ID: \"bot-list-users\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "ids", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/BotUserView" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List users" + } + }, + "/bot/users/prekeys": { + "post": { + "description": " [internal route ID: \"bot-claim-users-prekeys\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserClients" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserClientPrekeyMap" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "missing-legalhold-consent-old-clients", + "too-many-clients", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nToo many clients (label: `too-many-clients`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Claim users prekeys" + } + }, + "/bot/users/{User ID}/clients": { + "get": { + "description": " [internal route ID: \"bot-get-user-clients\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "User ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/PubClient" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Get user clients" + } + }, + "/broadcast/otr/messages": { + "post": { + "description": " [internal route ID: \"post-otr-broadcast-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "parameters": [ + { + "in": "query", + "name": "ignore_missing", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + }, + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Message sent" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "too-many-users-to-broadcast", + "message": "Too many users to fan out the broadcast event to" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-users-to-broadcast" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body` or `report_missing` or `ignore_missing`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "non-binding-team", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Broadcast an encrypted message to all team members and all contacts (accepts JSON or Protobuf)" + } + }, + "/broadcast/proteus/messages": { + "post": { + "description": " [internal route ID: \"post-proteus-broadcast\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "requestBody": { + "content": { + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/QualifiedNewOtrMessage" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Message sent" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "too-many-users-to-broadcast", + "message": "Too many users to fan out the broadcast event to" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-users-to-broadcast" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "non-binding-team", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Post an encrypted message to all team members and all contacts (accepts only Protobuf)" + } + }, + "/calls/config": { + "get": { + "deprecated": true, + "description": " [internal route ID: \"get-calls-config\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RTCConfiguration" + } + } + }, + "description": "" + } + }, + "summary": "Retrieve TURN server addresses and credentials for IP addresses, scheme `turn` and transport `udp` only (deprecated)" + } + }, + "/calls/config/v2": { + "get": { + "description": " [internal route ID: \"get-calls-config-v2\"]\n\n", + "parameters": [ + { + "description": "Limit resulting list. Allowed values [1..10]", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "maximum": 10, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RTCConfiguration" + } + } + }, + "description": "" + } + }, + "summary": "Retrieve all TURN server addresses and credentials. Clients are expected to do a DNS lookup to resolve the IP addresses of the given hostnames " + } + }, + "/clients": { + "get": { + "description": " [internal route ID: \"list-clients-v6\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientListv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientListv6" + } + } + }, + "description": "List of clients" + } + }, + "summary": "List the registered clients" + }, + "post": { + "description": " [internal route ID: \"add-client\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewClient" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Client" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Client" + } + } + }, + "description": "Client registered", + "headers": { + "Location": { + "description": "Client ID", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "bad-request", + "message": "Malformed prekeys uploaded" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "bad-request" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "missing-auth", + "too-many-clients" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nToo many clients (label: `too-many-clients`)" + } + }, + "summary": "Register a new client" + } + }, + "/clients/{cid}/access-token": { + "post": { + "description": " [internal route ID: \"create-access-token\"]\n\nCreate an JWT DPoP access token for the client CSR, given a JWT DPoP proof, specified in the `DPoP` header. The access token will be returned in the JSON response body as a JWT DPoP token.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "cid", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "DPoP", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DPoPAccessTokenResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DPoPAccessTokenResponse" + } + } + }, + "description": "Access token created", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Create a JWT DPoP access token" + } + }, + "/clients/{client}": { + "delete": { + "description": " [internal route ID: \"delete-client\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteClient" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Client deleted" + } + }, + "summary": "Delete an existing client" + }, + "get": { + "description": " [internal route ID: \"get-client-v6\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Clientv6" + } + } + }, + "description": "Client found" + }, + "404": { + "description": "`client` or Client not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get a registered client by ID" + }, + "put": { + "description": " [internal route ID: \"update-client\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateClient" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Client updated" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "bad-request", + "message": "Malformed prekeys uploaded" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "bad-request" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMalformed prekeys uploaded (label: `bad-request`)" + } + }, + "summary": "Update a registered client" + } + }, + "/clients/{client}/capabilities": { + "get": { + "description": " [internal route ID: \"get-client-capabilities\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientCapabilityList" + } + } + }, + "description": "" + } + }, + "summary": "Read back what the client has been posting about itself" + } + }, + "/clients/{client}/nonce": { + "get": { + "description": " [internal route ID: \"get-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "Replay-Nonce": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Get a new nonce for a client CSR" + }, + "head": { + "description": " [internal route ID: \"head-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "No Content", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + }, + "Replay-Nonce": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Get a new nonce for a client CSR" + } + }, + "/clients/{client}/prekeys": { + "get": { + "description": " [internal route ID: \"get-client-prekeys\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "summary": "List the remaining prekey IDs of a client" + } + }, + "/connections/{uid_domain}/{uid}": { + "get": { + "description": " [internal route ID: \"get-connection\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection found" + }, + "404": { + "description": "`uid_domain` or `uid` or Connection not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get an existing connection to another user (local or remote)" + }, + "post": { + "description": " [internal route ID: \"create-connection\"]\n\nYou can have no more than 1000 connections in accepted or sent state
Calls federation service brig on send-connection-action
Calls federation service brig on get-users-by-ids", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection existed" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection was created" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-user", + "message": "Invalid user" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-user" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid user (label: `invalid-user`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-identity", + "message": "The user has no verified email" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-identity", + "connection-limit", + "missing-legalhold-consent", + "missing-legalhold-consent-old-clients" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The user has no verified email (label: `no-identity`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)" + } + }, + "summary": "Create a connection to another user" + }, + "put": { + "description": " [internal route ID: \"update-connection\"]\n\nCalls federation service brig on send-connection-action
Calls federation service brig on get-users-by-ids", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConnectionUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserConnection" + } + } + }, + "description": "Connection updated" + }, + "204": { + "description": "Connection unchanged" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-user", + "message": "Invalid user" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-user" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid user (label: `invalid-user`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-identity", + "message": "The user has no verified email" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-identity", + "bad-conn-update", + "not-connected", + "connection-limit", + "missing-legalhold-consent", + "missing-legalhold-consent-old-clients" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The user has no verified email (label: `no-identity`)\n\nInvalid status transition (label: `bad-conn-update`)\n\nUsers are not connected (label: `not-connected`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)" + } + }, + "summary": "Update a connection to another user" + } + }, + "/conversations": { + "post": { + "description": " [internal route ID: \"create-group-conversation\"]\n\nThis returns 201 when a new conversation is created, and 200 when the conversation already existed
Calls federation service galley on on-conversation-updated
Calls federation service galley on on-conversation-created
Calls federation service brig on get-not-fully-connected-backends
Calls federation service brig on api-version", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewConv" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGroupConversationv6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateGroupConversationv6" + } + } + }, + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled", + "non-empty-member-list" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nAttempting to add group members outside MLS (label: `non-empty-member-list`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "operation-denied", + "no-team-member", + "not-connected", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nUsers are not connected (label: `not-connected`)\n\nConversation access denied (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Create a new conversation" + } + }, + "/conversations/code-check": { + "post": { + "description": " [internal route ID: \"code-check\"]\n\nIf the guest links team feature is disabled, this will fail with 404 CodeNotFound.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/join` which responds with 409 GuestLinksDisabled if guest links are disabled.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Valid" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-conversation-password", + "message": "Invalid conversation password" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid conversation password (label: `invalid-conversation-password`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + } + }, + "summary": "Check validity of a conversation code." + } + }, + "/conversations/join": { + "get": { + "description": " [internal route ID: \"get-conversation-by-reusable-code\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCoverView" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "access-denied", + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Get limited conversation information by key/code pair" + }, + "post": { + "description": " [internal route ID: \"join-conversation-by-code-unqualified\"]\n\nIf the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled.Calls federation service galley on on-conversation-updated", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/JoinConversationByCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation joined" + }, + "204": { + "description": "Conversation unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "too-many-members", + "message": "Maximum number of members per conversation reached" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-members", + "no-team-member", + "invalid-op", + "access-denied", + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Join a conversation using a reusable code" + } + }, + "/conversations/list": { + "post": { + "description": " [internal route ID: \"list-conversations\"]\n\nCalls federation service galley on get-conversations", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ListConversations" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationsResponse" + } + } + }, + "description": "" + } + }, + "summary": "Get conversation metadata for a list of conversation ids" + } + }, + "/conversations/list-ids": { + "post": { + "description": " [internal route ID: \"list-conversation-ids\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GetPaginated_ConversationIds" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationIds_Page" + } + } + }, + "description": "" + } + }, + "summary": "Get all conversation IDs." + } + }, + "/conversations/mls-self": { + "get": { + "description": " [internal route ID: \"get-mls-self-conversation\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Conversation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Conversation" + } + } + }, + "description": "The MLS self-conversation" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + } + }, + "summary": "Get the user's MLS self-conversation" + } + }, + "/conversations/one2one": { + "post": { + "description": " [internal route ID: \"create-one-to-one-conversation\"]\n\nCalls federation service galley on on-conversation-created", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewConv" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV3v3" + } + } + }, + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "operation-denied", + "not-connected", + "no-team-member", + "non-binding-team-members", + "invalid-op", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nBoth users must be members of the same binding team (label: `non-binding-team-members`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "non-binding-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)\n\nNot a member of a binding team (label: `non-binding-team`)" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Create a 1:1 conversation" + } + }, + "/conversations/one2one/{usr_domain}/{usr}": { + "get": { + "description": " [internal route ID: \"get-one-to-one-mls-conversation@v6\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "usr_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSOne2OneConversation_MLSPublicKey" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSOne2OneConversation_MLSPublicKey" + } + } + }, + "description": "MLS 1-1 conversation" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "not-connected", + "message": "Users are not connected" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-connected" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Users are not connected (label: `not-connected`)" + } + }, + "summary": "Get an MLS 1:1 conversation" + } + }, + "/conversations/self": { + "post": { + "description": " [internal route ID: \"create-self-conversation\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + } + }, + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationV6v6" + } + } + }, + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "schema": { + "format": "uuid", + "type": "string" + } + } + } + } + }, + "summary": "Create a self-conversation" + } + }, + "/conversations/{Conversation ID}/bots": { + "post": { + "description": " [internal route ID: \"add-bot\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "Conversation ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AddBot" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AddBotResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AddBotResponse" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "service-disabled", + "message": "The desired service is currently disabled." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "service-disabled", + "too-many-members", + "invalid-conversation", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The desired service is currently disabled. (label: `service-disabled`)\n\nMaximum number of members per conversation reached. (label: `too-many-members`)\n\nThe operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Add bot" + } + }, + "/conversations/{Conversation ID}/bots/{Bot ID}": { + "delete": { + "description": " [internal route ID: \"remove-bot\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "Conversation ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "Bot ID", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveBotResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RemoveBotResponse" + } + } + }, + "description": "User found" + }, + "204": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-conversation", + "message": "The operation is not allowed in this conversation." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-conversation", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Remove bot" + } + }, + "/conversations/{cnv_domain}/{cnv}": { + "get": { + "description": " [internal route ID: \"get-conversation\"]\n\nCalls federation service galley on get-conversations", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Conversation" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get a conversation by ID" + } + }, + "/conversations/{cnv_domain}/{cnv}/access": { + "put": { + "description": " [internal route ID: \"update-conversation-access\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationAccessData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Access updated" + }, + "204": { + "description": "Access unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid target access" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid target access (label: `invalid-op`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing modify_conversation_access) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update access modes for a conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/groupinfo": { + "get": { + "description": " [internal route ID: \"get-group-info\"]\n\nCalls federation service galley on query-group-info", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/GroupInfoData" + } + } + }, + "description": "The group information" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-missing-group-info", + "message": "The conversation has no group information" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-missing-group-info", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get MLS group information" + } + }, + "/conversations/{cnv_domain}/{cnv}/members": { + "post": { + "description": " [internal route ID: \"add-members-to-conversation\"]\n\nCalls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/InviteQualified" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation updated" + }, + "204": { + "description": "Conversation unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "not-connected", + "no-team-member", + "access-denied", + "too-many-members", + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Add qualified members to an existing conversation." + } + }, + "/conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}": { + "delete": { + "description": " [internal route ID: \"remove-member\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated
Calls federation service galley on leave-conversation", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "usr_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Target User ID", + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Member removed" + }, + "204": { + "description": "No change" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Remove a member from a conversation" + }, + "put": { + "description": " [internal route ID: \"update-other-member\"]\n\n**Note**: at least one field has to be provided.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "usr_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Target User ID", + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OtherMemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Membership updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation-member", + "message": "Conversation member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation-member", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `usr_domain` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update membership of the specified user" + } + }, + "/conversations/{cnv_domain}/{cnv}/message-timer": { + "put": { + "description": " [internal route ID: \"update-conversation-message-timer\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationMessageTimerUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Message timer updated" + }, + "204": { + "description": "Message timer unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update the message timer for a conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/name": { + "put": { + "description": " [internal route ID: \"update-conversation-name\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Name unchanged" + }, + "204": { + "description": "Name updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update conversation name" + } + }, + "/conversations/{cnv_domain}/{cnv}/proteus/messages": { + "post": { + "description": " [internal route ID: \"post-proteus-message\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.Calls federation service galley on send-message
Calls federation service galley on on-message-sent
Calls federation service brig on get-user-clients", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/QualifiedNewOtrMessage" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Message sent" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or Conversation not found (label: `no-conversation`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MessageSendingStatus" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Post an encrypted message to a conversation (accepts only Protobuf)" + } + }, + "/conversations/{cnv_domain}/{cnv}/protocol": { + "put": { + "description": " [internal route ID: \"update-conversation-protocol\"]\n\n**Note**: Only proteus->mixed upgrade is supported.", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProtocolUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation updated" + }, + "204": { + "description": "Conversation unchanged" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-migration-criteria-not-satisfied", + "message": "The migration criteria for mixed to MLS protocol transition are not satisfied for this conversation" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-migration-criteria-not-satisfied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nThe migration criteria for mixed to MLS protocol transition are not satisfied for this conversation (label: `mls-migration-criteria-not-satisfied`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "invalid-op", + "action-denied", + "invalid-protocol-transition" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nProtocol transition is invalid (label: `invalid-protocol-transition`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nTeam not found (label: `no-team`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update the protocol of the conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/receipt-mode": { + "put": { + "description": " [internal route ID: \"update-conversation-receipt-mode\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on update-conversation
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationReceiptModeUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Receipt mode updated" + }, + "204": { + "description": "Receipt mode unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update receipt mode for a conversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/self": { + "put": { + "description": " [internal route ID: \"update-conversation-self\"]\n\n**Note**: at least one field has to be provided.", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Update successful" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update self membership properties" + } + }, + "/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}": { + "delete": { + "description": " [internal route ID: \"delete-subconversation\"]\n\nCalls federation service galley on delete-sub-conversation", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteSubConversationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "Deletion successful" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "mls-stale-message", + "message": "The conversation epoch in a message is too old" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-stale-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The conversation epoch in a message is too old (label: `mls-stale-message`)" + } + }, + "summary": "Delete an MLS subconversation" + }, + "get": { + "description": " [internal route ID: \"get-subconversation\"]\n\nCalls federation service galley on get-sub-conversation", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSubConversation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PublicSubConversation" + } + } + }, + "description": "Subconversation" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-subconv-unsupported-convtype", + "message": "MLS subconversations are only supported for regular conversations" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-subconv-unsupported-convtype", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS subconversations are only supported for regular conversations (label: `mls-subconv-unsupported-convtype`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get information about an MLS subconversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/groupinfo": { + "get": { + "description": " [internal route ID: \"get-subconversation-group-info\"]\n\nCalls federation service galley on query-group-info", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/GroupInfoData" + } + } + }, + "description": "The group information" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-missing-group-info", + "message": "The conversation has no group information" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-missing-group-info", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nThe conversation has no group information (label: `mls-missing-group-info`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get MLS group information of subconversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/subconversations/{subconv}/self": { + "delete": { + "description": " [internal route ID: \"leave-subconversation\"]\n\nCalls federation service galley on leave-sub-conversation
Calls federation service galley on on-mls-message-sent", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "subconv", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled", + "mls-protocol-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nMLS protocol error (label: `mls-protocol-error`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` or `subconv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "mls-stale-message", + "message": "The conversation epoch in a message is too old" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-stale-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The conversation epoch in a message is too old (label: `mls-stale-message`)" + } + }, + "summary": "Leave an MLS subconversation" + } + }, + "/conversations/{cnv_domain}/{cnv}/typing": { + "post": { + "description": " [internal route ID: \"member-typing-qualified\"]\n\nCalls federation service galley on on-typing-indicator-updated
Calls federation service galley on update-typing-indicator", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TypingData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Notification sent" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv_domain` or `cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Sending typing notifications" + } + }, + "/conversations/{cnv}": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-name-deprecated\"]\n\nUse `/conversations/:domain/:conv/name` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Name updated" + }, + "204": { + "description": "Name unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update conversation name (deprecated)" + } + }, + "/conversations/{cnv}/code": { + "delete": { + "description": " [internal route ID: \"remove-code-unqualified\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation code deleted." + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Delete conversation code" + }, + "get": { + "description": " [internal route ID: \"get-code\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + } + }, + "description": "Conversation Code" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Get existing conversation code" + }, + "post": { + "description": " [internal route ID: \"create-conversation-code-unqualified\"]\n\n\nOAuth scope: `write:conversations_code`", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateConversationCodeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationCodeInfo" + } + } + }, + "description": "Conversation code already exists." + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Conversation code created." + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "create-conv-code-conflict", + "message": "Conversation code already exists with a different password setting than the requested one." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "create-conv-code-conflict", + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation code already exists with a different password setting than the requested one. (label: `create-conv-code-conflict`)\n\nThe guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)" + } + }, + "summary": "Create or recreate a conversation code" + } + }, + "/conversations/{cnv}/features/conversationGuestLinks": { + "get": { + "description": " [internal route ID: \"get-conversation-guest-links-status\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get the status of the guest links feature for a conversation that potentially has been created by someone from another team." + } + }, + "/conversations/{cnv}/members/{usr}": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-other-member-unqualified\"]\n\nUse `PUT /conversations/:cnv_domain/:cnv/members/:usr_domain/:usr` insteadCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Target User ID", + "in": "path", + "name": "usr", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OtherMemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Membership updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation-member", + "message": "Conversation member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation-member", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` or `usr` not found\n\nConversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update membership of the specified user (deprecated)" + } + }, + "/conversations/{cnv}/message-timer": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-message-timer-unqualified\"]\n\nUse `/conversations/:domain/:cnv/message-timer` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationMessageTimerUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Message timer updated" + }, + "204": { + "description": "Message timer unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update the message timer for a conversation (deprecated)" + } + }, + "/conversations/{cnv}/name": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-name-unqualified\"]\n\nUse `/conversations/:domain/:conv/name` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRename" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Name updated" + }, + "204": { + "description": "Name unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update conversation name (deprecated)" + } + }, + "/conversations/{cnv}/otr/messages": { + "post": { + "description": " [internal route ID: \"post-otr-message-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.Calls federation service brig on get-user-clients
Calls federation service galley on on-message-sent", + "parameters": [ + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "ignore_missing", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + }, + "application/x-protobuf": { + "schema": { + "$ref": "#/components/schemas/new-otr-message" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Message sent" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent-old-clients", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has old clients that do not support legalhold's UI requirements (label: `missing-legalhold-consent-old-clients`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` or Conversation not found (label: `no-conversation`)" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientMismatch" + } + } + }, + "description": "Missing clients" + } + }, + "summary": "Post an encrypted message to a conversation (accepts JSON or Protobuf)" + } + }, + "/conversations/{cnv}/receipt-mode": { + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-receipt-mode-unqualified\"]\n\nUse `PUT /conversations/:domain/:cnv/receipt-mode` instead.Calls federation service brig on get-users-by-ids
Calls federation service galley on update-conversation
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationReceiptModeUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Event" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Event" + } + } + }, + "description": "Receipt mode updated" + }, + "204": { + "description": "Receipt mode unchanged" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update receipt mode for a conversation (deprecated)" + } + }, + "/conversations/{cnv}/roles": { + "get": { + "description": " [internal route ID: \"get-conversation-roles\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRolesList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Conversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get existing roles available for the given conversation" + } + }, + "/conversations/{cnv}/self": { + "get": { + "deprecated": true, + "description": " [internal route ID: \"get-conversation-self-unqualified\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + }, + "description": "" + } + }, + "summary": "Get self membership properties (deprecated)" + }, + "put": { + "deprecated": true, + "description": " [internal route ID: \"update-conversation-self-unqualified\"]\n\nUse `/conversations/:domain/:conv/self` instead.", + "parameters": [ + { + "description": "Conversation ID", + "in": "path", + "name": "cnv", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Update successful" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`cnv` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Update self membership properties (deprecated)" + } + }, + "/cookies": { + "get": { + "description": " [internal route ID: \"list-cookies\"]\n\n", + "parameters": [ + { + "description": "Filter by label (comma-separated list)", + "in": "query", + "name": "labels", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CookieList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CookieList" + } + } + }, + "description": "List of cookies" + } + }, + "summary": "Retrieve the list of cookies currently stored for the user" + } + }, + "/cookies/remove": { + "post": { + "description": " [internal route ID: \"remove-cookies\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RemoveCookies" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Cookies revoked" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Revoke stored cookies" + } + }, + "/custom-backend/by-domain/{domain}": { + "get": { + "description": " [internal route ID: \"get-custom-backend-by-domain\"]\n\n", + "parameters": [ + { + "description": "URL-encoded email domain", + "in": "path", + "name": "domain", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CustomBackend" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "custom-backend-not-found", + "message": "Custom backend not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "custom-backend-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`domain` not found\n\nCustom backend not found (label: `custom-backend-not-found`)" + } + }, + "summary": "Shows information about custom backends related to a given email domain" + } + }, + "/delete": { + "post": { + "description": " [internal route ID: \"verify-delete\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/VerifyDeleteUser" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Deletion is initiated." + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-code", + "message": "Invalid verification code" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid verification code (label: `invalid-code`)" + } + }, + "summary": "Verify account deletion with a code." + } + }, + "/feature-configs": { + "get": { + "description": " [internal route ID: \"get-all-feature-configs-for-user\"]\n\nGets feature configs for a user. If the user is a member of a team and has the required permissions, this will return the team's feature configs.If the user is not a member of a team, this will return the personal feature configs (the server defaults).\nOAuth scope: `read:feature_configs`", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AllTeamFeatures" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)" + } + }, + "summary": "Gets feature configs for a user" + } + }, + "/identity-providers": { + "get": { + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPList" + } + } + }, + "description": "" + } + } + }, + "post": { + "parameters": [ + { + "in": "query", + "name": "replaces", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "api_version", + "required": false, + "schema": { + "default": "v2", + "enum": [ + "v1", + "v2" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "handle", + "required": false, + "schema": { + "maxLength": 1, + "minLength": 32, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + } + } + }, + "description": "" + } + } + } + }, + "/identity-providers/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "purge", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "204": { + "description": "" + } + } + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + } + } + }, + "description": "" + } + } + }, + "put": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "handle", + "required": false, + "schema": { + "maxLength": 1, + "minLength": 32, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/IdPMetadataInfo" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/IdPConfig_WireIdP" + } + } + }, + "description": "" + } + } + } + }, + "/identity-providers/{id}/raw": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/list-connections": { + "post": { + "description": " [internal route ID: \"list-connections\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GetPaginated_Connections" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Connections_Page" + } + } + }, + "description": "" + } + }, + "summary": "List the connections to other users, including remote users" + } + }, + "/list-users": { + "post": { + "description": " [internal route ID: \"list-users-by-ids-or-handles\"]\n\nThe 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive.Calls federation service brig on get-users-by-ids", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ListUsersQuery" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ListUsersById" + } + } + }, + "description": "" + } + }, + "summary": "List users" + } + }, + "/login": { + "post": { + "description": " [internal route ID: \"login\"]\n\nLogins are throttled at the server's discretionCalls federation service brig on send-connection-action", + "parameters": [ + { + "description": "Request a persistent cookie instead of a session cookie", + "in": "query", + "name": "persist", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Login" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AccessToken" + } + } + }, + "description": "OK", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "pending-activation", + "suspended", + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nAccount pending activation (label: `pending-activation`)\n\nAccount suspended (label: `suspended`)\n\nAuthentication failed (label: `invalid-credentials`)" + } + }, + "summary": "Authenticate a user to obtain a cookie and first access token" + } + }, + "/mls/commit-bundles": { + "post": { + "description": " [internal route ID: \"mls-commit-bundle\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.
Calls federation service brig on api-version
Calls federation service brig on get-users-by-ids
Calls federation service brig on get-mls-clients
Calls federation service galley on on-conversation-updated
Calls federation service galley on send-mls-commit-bundle
Calls federation service galley on mls-welcome
Calls federation service galley on on-mls-message-sent", + "requestBody": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/CommitBundle" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + } + }, + "description": "Commit accepted and forwarded" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-welcome-mismatch", + "message": "The list of targets of a welcome message does not match the list of new clients in a group" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-welcome-mismatch", + "mls-self-removal-not-allowed", + "mls-protocol-error", + "mls-not-enabled", + "mls-invalid-leaf-node-index", + "mls-group-conversation-mismatch", + "mls-commit-missing-references", + "mls-client-sender-user-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nThe list of targets of a welcome message does not match the list of new clients in a group (label: `mls-welcome-mismatch`)\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-subconv-join-parent-missing", + "message": "MLS client cannot join the subconversation because it is not member of the parent conversation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-subconv-join-parent-missing", + "missing-legalhold-consent", + "legalhold-not-enabled", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-proposal-not-found", + "message": "A proposal referenced in a commit message could not be found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-proposal-not-found", + "no-conversation", + "no-conversation-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)" + }, + "422": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 422, + "label": "mls-unsupported-proposal", + "message": "Unsupported proposal type" + }, + "properties": { + "code": { + "enum": [ + 422 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-unsupported-proposal", + "mls-unsupported-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Post a MLS CommitBundle" + } + }, + "/mls/key-packages/claim/{user_domain}/{user}": { + "post": { + "description": " [internal route ID: \"mls-key-packages-claim\"]\n\nOnly key packages for the specified ciphersuite are claimed. For backwards compatibility, the `ciphersuite` parameter is optional, defaulting to ciphersuite 0x0001 when omitted.", + "parameters": [ + { + "in": "path", + "name": "user_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "user", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuite", + "required": false, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/KeyPackageBundle" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/KeyPackageBundle" + } + } + }, + "description": "Claimed key packages" + } + }, + "summary": "Claim one key package for each client of the given user" + } + }, + "/mls/key-packages/self/{client}": { + "delete": { + "description": " [internal route ID: \"mls-key-packages-delete\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuite", + "required": false, + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteKeyPackages" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "OK" + } + }, + "summary": "Delete all key packages for a given ciphersuite and client" + }, + "post": { + "description": " [internal route ID: \"mls-key-packages-upload\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/KeyPackageUpload" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Key packages uploaded" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-protocol-error", + "message": "MLS protocol error" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-protocol-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nMLS protocol error (label: `mls-protocol-error`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-identity-mismatch", + "message": "Key package credential does not match qualified client ID" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-identity-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)" + } + }, + "summary": "Upload a fresh batch of key packages" + }, + "put": { + "description": " [internal route ID: \"mls-key-packages-replace\"]\n\nThe request body should be a json object containing a list of base64-encoded key packages. Use this sparingly.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Comma-separated list of ciphersuites in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuites", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/KeyPackageUpload" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Key packages replaced" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-protocol-error", + "message": "MLS protocol error" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-protocol-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body` or `ciphersuites`\n\nMLS protocol error (label: `mls-protocol-error`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-identity-mismatch", + "message": "Key package credential does not match qualified client ID" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-identity-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Key package credential does not match qualified client ID (label: `mls-identity-mismatch`)" + } + }, + "summary": "Upload a fresh batch of key packages and replace the old ones" + } + }, + "/mls/key-packages/self/{client}/count": { + "get": { + "description": " [internal route ID: \"mls-key-packages-count\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001", + "in": "query", + "name": "ciphersuite", + "required": false, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OwnKeyPackages" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OwnKeyPackages" + } + } + }, + "description": "Number of key packages" + } + }, + "summary": "Return the number of unclaimed key packages for a given ciphersuite and client" + } + }, + "/mls/messages": { + "post": { + "description": " [internal route ID: \"mls-message\"]\n\n\n\n**Note**: this endpoint can execute proposals, and therefore return all possible errors associated with adding or removing members to a conversation, in addition to the ones listed below. See the documentation of [POST /conversations/{cnv}/members/v2](#/default/post_conversations__cnv__members_v2) and [POST /conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}](#/default/delete_conversations__cnv_domain___cnv__members__usr_domain___usr_) for more details on the possible error responses of each type of proposal.
Calls federation service brig on get-mls-clients
Calls federation service galley on on-conversation-updated
Calls federation service galley on send-mls-message
Calls federation service galley on on-mls-message-sent", + "requestBody": { + "content": { + "message/mls": { + "schema": { + "$ref": "#/components/schemas/MLSMessage" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSMessageSendingStatus" + } + } + }, + "description": "Message sent" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-self-removal-not-allowed", + "message": "Self removal from group is not allowed" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-self-removal-not-allowed", + "mls-protocol-error", + "mls-not-enabled", + "mls-invalid-leaf-node-index", + "mls-group-conversation-mismatch", + "mls-commit-missing-references", + "mls-client-sender-user-mismatch" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nSelf removal from group is not allowed (label: `mls-self-removal-not-allowed`)\n\nMLS protocol error (label: `mls-protocol-error`)\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nA referenced leaf node index points to a blank or non-existing node (label: `mls-invalid-leaf-node-index`)\n\nConversation ID resolved from Group ID does not match submitted Conversation ID (label: `mls-group-conversation-mismatch`)\n\nThe commit is not referencing all pending proposals (label: `mls-commit-missing-references`)\n\nUser ID resolved from Client ID does not match message's sender user ID (label: `mls-client-sender-user-mismatch`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "mls-subconv-join-parent-missing", + "message": "MLS client cannot join the subconversation because it is not member of the parent conversation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-subconv-join-parent-missing", + "missing-legalhold-consent", + "legalhold-not-enabled", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "MLS client cannot join the subconversation because it is not member of the parent conversation (label: `mls-subconv-join-parent-missing`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nConversation access denied (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "mls-proposal-not-found", + "message": "A proposal referenced in a commit message could not be found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-proposal-not-found", + "no-conversation", + "no-conversation-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "A proposal referenced in a commit message could not be found (label: `mls-proposal-not-found`)\n\nConversation not found (label: `no-conversation`)\n\nConversation member not found (label: `no-conversation-member`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + } + }, + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph\n\nThe conversation epoch in a message is too old (label: `mls-stale-message`)\n\nA proposal of type Add or Remove does not apply to the full list of clients for a user (label: `mls-client-mismatch`)" + }, + "422": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 422, + "label": "mls-unsupported-proposal", + "message": "Unsupported proposal type" + }, + "properties": { + "code": { + "enum": [ + 422 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-unsupported-proposal", + "mls-unsupported-message" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unsupported proposal type (label: `mls-unsupported-proposal`)\n\nAttempted to send a message with an unsupported combination of content type and wire format (label: `mls-unsupported-message`)" + }, + "533": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/components/schemas/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "description": "Some domains are unreachable" + } + }, + "summary": "Post an MLS message" + } + }, + "/mls/public-keys": { + "get": { + "description": " [internal route ID: \"mls-public-keys\"]\n\nThe format of the returned key is determined by the `format` query parameter:\n - raw (default): base64-encoded raw public keys\n - jwk: keys are nested objects in JWK format.", + "parameters": [ + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "enum": [ + "raw", + "jwk" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MLSKeysByPurpose" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSKeysByPurpose" + } + } + }, + "description": "Public keys" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "mls-not-enabled", + "message": "MLS is not configured on this backend. See docs.wire.com for instructions on how to enable it" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "mls-not-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `format`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)" + } + }, + "summary": "Get public keys used by the backend to sign external proposals" + } + }, + "/notifications": { + "get": { + "description": " [internal route ID: \"get-notifications\"]\n\n", + "parameters": [ + { + "description": "Only return notifications more recent than this", + "in": "query", + "name": "since", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Maximum number of notifications to return", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 10000, + "minimum": 100, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueuedNotificationList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotificationList" + } + } + }, + "description": "Notification list" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Some notifications not found (label: `not-found`)" + } + }, + "summary": "Fetch notifications" + } + }, + "/notifications/last": { + "get": { + "description": " [internal route ID: \"get-last-notification\"]\n\n", + "parameters": [ + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + } + }, + "description": "Notification found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Some notifications not found (label: `not-found`)" + } + }, + "summary": "Fetch the last notification" + } + }, + "/notifications/{id}": { + "get": { + "description": " [internal route ID: \"get-notification-by-id\"]\n\n", + "parameters": [ + { + "description": "Notification ID", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotification" + } + } + }, + "description": "Notification found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`id` or Some notifications not found (label: `not-found`)" + } + }, + "summary": "Fetch a notification by ID" + } + }, + "/oauth/applications": { + "get": { + "description": " [internal route ID: \"get-oauth-applications\"]\n\nGet all OAuth applications with active account access for a user.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/OAuthApplication" + }, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/OAuthApplication" + }, + "type": "array" + } + } + }, + "description": "OAuth applications found" + } + }, + "summary": "Get OAuth applications with account access" + } + }, + "/oauth/applications/{OAuthClientId}": { + "delete": { + "description": " [internal route ID: \"revoke-oauth-account-access-v6\"]\n\n", + "parameters": [ + { + "description": "The ID of the OAuth client", + "in": "path", + "name": "OAuthClientId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "OAuth application access revoked" + } + }, + "summary": "Revoke account access from an OAuth application" + } + }, + "/oauth/applications/{OAuthClientId}/sessions/{RefreshTokenId}": { + "delete": { + "description": " [internal route ID: \"delete-oauth-refresh-token\"]\n\nRevoke an active OAuth session by providing the refresh token ID.", + "parameters": [ + { + "description": "The ID of the OAuth client", + "in": "path", + "name": "OAuthClientId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "The ID of the refresh token", + "in": "path", + "name": "RefreshTokenId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordReqBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`OAuthClientId` or `RefreshTokenId` not found\n\nOAuth client not found (label: `not-found`)" + } + }, + "summary": "Revoke an active OAuth session" + } + }, + "/oauth/authorization/codes": { + "post": { + "description": " [internal route ID: \"create-oauth-auth-code\"]\n\nCurrently only supports the 'code' response type, which corresponds to the authorization code flow.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateOAuthAuthorizationCodeRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 400, + "label": "redirect-url-miss-match", + "message": "The redirect URL does not match the one registered with the client" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "redirect-url-miss-match" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "redirect-url-miss-match", + "message": "The redirect URL does not match the one registered with the client" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "redirect-url-miss-match" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Bad Request\n\nThe redirect URL does not match the one registered with the client (label: `redirect-url-miss-match`) or `body`", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + } + }, + "summary": "Create an OAuth authorization code" + } + }, + "/oauth/clients/{OAuthClientId}": { + "get": { + "description": " [internal route ID: \"get-oauth-client\"]\n\n", + "parameters": [ + { + "description": "The ID of the OAuth client", + "in": "path", + "name": "OAuthClientId", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuthClient" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OAuthClient" + } + } + }, + "description": "OAuth client found" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "forbidden", + "message": "OAuth is disabled" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "OAuth is disabled (label: `forbidden`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`OAuthClientId` or OAuth client not found (label: `not-found`)\n\nOAuth client not found (label: `not-found`)" + } + }, + "summary": "Get OAuth client information" + } + }, + "/oauth/revoke": { + "post": { + "description": " [internal route ID: \"revoke-oauth-refresh-token\"]\n\nRevoke an access token.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OAuthRevokeRefreshTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "forbidden", + "message": "Invalid refresh token" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid refresh token (label: `forbidden`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "OAuth client not found (label: `not-found`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "jwt-error", + "message": "Internal error while handling JWT token" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "jwt-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Internal error while handling JWT token (label: `jwt-error`)" + } + }, + "summary": "Revoke an OAuth refresh token" + } + }, + "/oauth/token": { + "post": { + "description": " [internal route ID: \"create-oauth-access-token\"]\n\nObtain a new access token from an authorization code or a refresh token.", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Either_OAuthAccessTokenRequest_OAuthRefreshAccessTokenRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OAuthAccessTokenResponse" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid_grant", + "message": "Invalid grant" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid_grant", + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid grant (label: `invalid_grant`)\n\nInvalid client credentials (label: `forbidden`)\n\nInvalid grant type (label: `forbidden`)\n\nInvalid refresh token (label: `forbidden`)\n\nOAuth is disabled (label: `forbidden`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "OAuth client not found (label: `not-found`)\n\nOAuth authorization code not found (label: `not-found`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "jwt-error", + "message": "Internal error while handling JWT token" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "jwt-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Internal error while handling JWT token (label: `jwt-error`)" + } + }, + "summary": "Create an OAuth access token" + } + }, + "/onboarding/v3": { + "post": { + "deprecated": true, + "description": " [internal route ID: \"onboarding\"]\n\nDEPRECATED: the feature has been turned off, the end-point does nothing and always returns '{\"results\":[],\"auto-connects\":[]}'.", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Body" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeprecatedMatchingResult" + } + } + }, + "description": "" + } + }, + "summary": "Upload contacts and invoke matching." + } + }, + "/password-reset": { + "post": { + "description": " [internal route ID: \"post-password-reset\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewPasswordReset" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Password reset code created and sent by email." + } + }, + "summary": "Initiate a password reset." + } + }, + "/password-reset/complete": { + "post": { + "description": " [internal route ID: \"post-password-reset-complete\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CompletePasswordReset" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password reset successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)" + } + }, + "summary": "Complete a password reset." + } + }, + "/password-reset/{key}": { + "post": { + "deprecated": true, + "description": " [internal route ID: \"post-password-reset-key-deprecated\"]\n\nDEPRECATED: Use 'POST /password-reset/complete'.", + "parameters": [ + { + "description": "An opaque key for a pending password reset.", + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordReset" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password reset successful." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code", + "invalid-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ", + "code-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)" + } + }, + "summary": "Complete a password reset." + } + }, + "/properties": { + "delete": { + "description": " [internal route ID: \"clear-properties\"]\n\n", + "responses": { + "200": { + "description": "Properties cleared" + } + }, + "summary": "Clear all properties" + }, + "get": { + "description": " [internal route ID: \"list-property-keys\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/ASCII" + }, + "type": "array" + } + } + }, + "description": "List of property keys" + } + }, + "summary": "List all property keys" + } + }, + "/properties-values": { + "get": { + "description": " [internal route ID: \"list-properties\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PropertyKeysAndValues" + } + } + }, + "description": "" + } + }, + "summary": "List all properties with key and value" + } + }, + "/properties/{key}": { + "delete": { + "description": " [internal route ID: \"delete-property\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "format": "printable", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Property deleted" + } + }, + "summary": "Delete a property" + }, + "get": { + "description": " [internal route ID: \"get-property\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "format": "printable", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PropertyValue" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PropertyValue" + } + } + }, + "description": "The property value" + }, + "404": { + "description": "`key` or Property not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get a property value" + }, + "put": { + "description": " [internal route ID: \"set-property\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "format": "printable", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PropertyValue" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Property set" + } + }, + "summary": "Set a user property" + } + }, + "/provider": { + "delete": { + "description": " [internal route ID: \"provider-delete\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteProvider" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "invalid-provider", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Delete a provider" + }, + "get": { + "description": " [internal route ID: \"provider-get-account\"]\n\n", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Provider not found. (label: `not-found`)\n\nProvider not found. (label: `not-found`)" + } + }, + "summary": "Get account" + }, + "put": { + "description": " [internal route ID: \"provider-update\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateProvider" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-provider", + "message": "The provider does not exist." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-provider", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Update a provider" + } + }, + "/provider/activate": { + "get": { + "description": " [internal route ID: \"provider-activate\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProviderActivationResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProviderActivationResponse" + } + } + }, + "description": "" + }, + "204": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-code", + "message": "Invalid verification code" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Activate a provider" + } + }, + "/provider/assets": { + "post": { + "requestBody": { + "content": { + "multipart/mixed": { + "schema": { + "$ref": "#/components/schemas/AssetSource" + } + } + }, + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server." + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Asset" + } + } + }, + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-length", + "message": "Invalid content length" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-length" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Asset too large (label: `client-error`)" + } + }, + "summary": "Upload an asset" + } + }, + "/provider/assets/{key}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorised operation (label: `unauthorised`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` not found\n\nAsset not found (label: `not-found`)" + } + }, + "summary": "Delete an asset" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`key` or Asset not found (label: `not-found`)" + } + }, + "summary": "Download an asset" + } + }, + "/provider/email": { + "put": { + "description": " [internal route ID: \"provider-update-email\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EmailUpdate" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-provider", + "message": "The provider does not exist." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-provider", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The provider does not exist. (label: `invalid-provider`)\n\nAccess denied. (label: `access-denied`)" + }, + "429": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 429, + "label": "too-many-requests", + "message": "Too many request to generate a verification code." + }, + "properties": { + "code": { + "enum": [ + 429 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-requests" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many request to generate a verification code. (label: `too-many-requests`)" + } + }, + "summary": "Update a provider email" + } + }, + "/provider/login": { + "post": { + "description": " [internal route ID: \"provider-login\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ProviderLogin" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Set-Cookie": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + } + }, + "summary": "Login as a provider" + } + }, + "/provider/password": { + "put": { + "description": " [internal route ID: \"provider-update-password\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordChange" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)" + } + }, + "summary": "Update a provider password" + } + }, + "/provider/password-reset": { + "post": { + "description": " [internal route ID: \"provider-password-reset\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordReset" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code", + "invalid-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ", + "code-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)\n\nA password reset is already in progress. (label: `code-exists`)" + }, + "429": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 429, + "label": "too-many-requests", + "message": "Too many request to generate a verification code." + }, + "properties": { + "code": { + "enum": [ + 429 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-requests" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many request to generate a verification code. (label: `too-many-requests`)" + } + }, + "summary": "Begin a password reset" + } + }, + "/provider/password-reset/complete": { + "post": { + "description": " [internal route ID: \"provider-password-reset-complete\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CompletePasswordReset" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-code", + "message": "Invalid password reset code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "invalid-code", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)\n\nAccess denied. (label: `access-denied`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)" + } + }, + "summary": "Complete a password reset" + } + }, + "/provider/register": { + "post": { + "description": " [internal route ID: \"provider-register\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewProvider" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewProviderResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewProviderResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "429": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 429, + "label": "too-many-requests", + "message": "Too many request to generate a verification code." + }, + "properties": { + "code": { + "enum": [ + 429 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-requests" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many request to generate a verification code. (label: `too-many-requests`)" + } + }, + "summary": "Register a new provider" + } + }, + "/provider/services": { + "get": { + "description": " [internal route ID: \"get-provider-services\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/Service" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List provider services" + }, + "post": { + "description": " [internal route ID: \"post-provider-services\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewService" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewServiceResponse" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewServiceResponse" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-service-key", + "message": "Invalid service key." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-service-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Create a new service" + } + }, + "/provider/services/{service-id}": { + "delete": { + "description": " [internal route ID: \"delete-provider-services-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteService" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Delete service" + }, + "get": { + "description": " [internal route ID: \"get-provider-services-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Service" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Get provider service by service id" + }, + "put": { + "description": " [internal route ID: \"put-provider-services-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateService" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Provider service updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nProvider not found. (label: `not-found`)\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Update provider service" + } + }, + "/provider/services/{service-id}/connection": { + "put": { + "description": " [internal route ID: \"put-provider-services-connection-by-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateServiceConn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Provider service connection updated" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-service-key", + "message": "Invalid service key." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-service-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid service key. (label: `invalid-service-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nAccess denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Update provider service connection" + } + }, + "/providers/{pid}": { + "get": { + "description": " [internal route ID: \"provider-get-profile\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "pid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Provider" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Provider not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`pid` or Provider not found. (label: `not-found`)" + } + }, + "summary": "Get profile" + } + }, + "/providers/{provider-id}/services": { + "get": { + "description": " [internal route ID: \"get-provider-services-by-provider-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "provider-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/ServiceProfile" + }, + "type": "array" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "Get provider services by provider id" + } + }, + "/providers/{provider-id}/services/{service-id}": { + "get": { + "description": " [internal route ID: \"get-provider-services-by-provider-id-and-service-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "provider-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "service-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceProfile" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Service not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`provider-id` or `service-id` not found\n\nService not found. (label: `not-found`)" + } + }, + "summary": "Get provider service by provider id and service id" + } + }, + "/proxy/giphy/v1/gifs": {}, + "/proxy/googlemaps/api/staticmap": {}, + "/proxy/googlemaps/maps/api/geocode": {}, + "/proxy/youtube/v3": {}, + "/push/tokens": { + "get": { + "description": " [internal route ID: \"get-push-tokens\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PushTokenList" + } + } + }, + "description": "" + } + }, + "summary": "List the user's registered push tokens" + }, + "post": { + "description": " [internal route ID: \"register-push-token\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PushToken" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushToken" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PushToken" + } + } + }, + "description": "Push token registered", + "headers": { + "Location": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 400, + "label": "apns-voip-not-supported", + "message": "Adding APNS_VOIP tokens is not supported" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "apns-voip-not-supported" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "apns-voip-not-supported", + "message": "Adding APNS_VOIP tokens is not supported" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "apns-voip-not-supported" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Adding APNS_VOIP tokens is not supported (label: `apns-voip-not-supported`) or `body`" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "app-not-found", + "message": "App does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "app-not-found", + "invalid-token" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "app-not-found", + "message": "App does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "app-not-found", + "invalid-token" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "App does not exist (label: `app-not-found`)\n\nInvalid push token (label: `invalid-token`)" + }, + "413": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 413, + "label": "sns-thread-budget-reached", + "message": "Too many concurrent calls to SNS; is SNS down?" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "sns-thread-budget-reached", + "token-too-long", + "metadata-too-long" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 413, + "label": "sns-thread-budget-reached", + "message": "Too many concurrent calls to SNS; is SNS down?" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "sns-thread-budget-reached", + "token-too-long", + "metadata-too-long" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Too many concurrent calls to SNS; is SNS down? (label: `sns-thread-budget-reached`)\n\nPush token length must be < 8192 for GCM or 400 for APNS (label: `token-too-long`)\n\nTried to add token to endpoint resulting in metadata length > 2048 (label: `metadata-too-long`)" + } + }, + "summary": "Register a native push token" + } + }, + "/push/tokens/{pid}": { + "delete": { + "description": " [internal route ID: \"delete-push-token\"]\n\n", + "parameters": [ + { + "description": "The push token to delete", + "in": "path", + "name": "pid", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Push token unregistered" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Push token not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Push token not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`pid` or Push token not found (label: `not-found`)" + } + }, + "summary": "Unregister a native push token" + } + }, + "/register": { + "post": { + "description": " [internal route ID: \"register\"]\n\nIf the environment where the registration takes place is private and a registered email address is not whitelisted, a 403 error is returned.Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewUser" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "User created and pending activation", + "headers": { + "Location": { + "description": "UserId", + "schema": { + "format": "uuid", + "type": "string" + } + }, + "Set-Cookie": { + "description": "Cookie", + "schema": { + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code", + "invalid-email", + "invalid-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code", + "invalid-email", + "invalid-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)\n\nInvalid mobile phone number (label: `invalid-phone`) or `body`" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "unauthorized", + "message": "Unauthorized e-mail address" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorized", + "missing-identity", + "blacklisted-email", + "too-many-team-members", + "user-creation-restricted" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "unauthorized", + "message": "Unauthorized e-mail address" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorized", + "missing-identity", + "blacklisted-email", + "too-many-team-members", + "user-creation-restricted" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Unauthorized e-mail address (label: `unauthorized`)\n\nUsing an invitation code requires registering the given email. (label: `missing-identity`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nToo many members in this team. (label: `too-many-team-members`)\n\nThis instance does not allow creation of personal users or teams. (label: `user-creation-restricted`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "User does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "User does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "User does not exist (label: `invalid-code`)\n\nInvalid activation code (label: `invalid-code`)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given e-mail address is in use. (label: `key-exists`)" + } + }, + "summary": "Register a new user." + } + }, + "/scim/auth-tokens": { + "delete": { + "parameters": [ + { + "in": "query", + "name": "id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)" + } + } + }, + "get": { + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ScimTokenList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)" + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateScimToken" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CreateScimTokenResponse" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)" + } + } + } + }, + "/search/contacts": { + "get": { + "description": " [internal route ID: \"search-contacts\"]\n\nCalls federation service brig on search-users
Calls federation service brig on get-users-by-ids", + "parameters": [ + { + "description": "Search query", + "in": "query", + "name": "q", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Searched domain. Note: This is optional only for backwards compatibility, future versions will mandate this.", + "in": "query", + "name": "domain", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Number of results to return (min: 1, max: 500, default 15)", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + } + }, + "description": "" + } + }, + "summary": "Search for users" + } + }, + "/self": { + "delete": { + "description": " [internal route ID: \"delete-self\"]\n\nif the account has a verified identity, a verification code is sent and needs to be confirmed to authorise the deletion. if the account has no verified identity but a password, it must be provided. if password is correct, or if neither a verified identity nor a password exists, account deletion is scheduled immediately.Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeleteUser" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Deletion is initiated." + }, + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeletionCodeTimeout" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DeletionCodeTimeout" + } + } + }, + "description": "Deletion is pending verification with a code." + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-user", + "message": "Invalid user" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-user" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid user (label: `invalid-user`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-self-delete-for-team-owner", + "message": "Team owners are not allowed to delete themselves; ask a fellow owner" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-self-delete-for-team-owner", + "pending-delete", + "missing-auth", + "invalid-credentials", + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team owners are not allowed to delete themselves; ask a fellow owner (label: `no-self-delete-for-team-owner`)\n\nA verification code for account deletion is still pending (label: `pending-delete`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)" + } + }, + "summary": "Initiate account deletion." + }, + "get": { + "description": " [internal route ID: \"get-self\"]\n\n\nOAuth scope: `read:self`", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + }, + "description": "" + } + }, + "summary": "Get your own profile" + }, + "put": { + "description": " [internal route ID: \"put-self\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "User updated" + } + }, + "summary": "Update your profile." + } + }, + "/self/email": { + "delete": { + "description": " [internal route ID: \"remove-email\"]\n\nYour email address can only be removed if you also have a phone number.Calls federation service brig on send-connection-action", + "responses": { + "200": { + "description": "Identity Removed" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "last-identity", + "message": "The last user identity cannot be removed." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "last-identity", + "no-password", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "last-identity", + "message": "The last user identity cannot be removed." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "last-identity", + "no-password", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The last user identity cannot be removed. (label: `last-identity`)\n\nThe user has no password. (label: `no-password`)\n\nThe user has no verified email (label: `no-identity`)" + } + }, + "summary": "Remove your email address." + } + }, + "/self/handle": { + "put": { + "description": " [internal route ID: \"change-handle\"]\n\nCalls federation service brig on send-connection-action
Calls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/HandleUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Handle Changed" + } + }, + "summary": "Change your handle." + } + }, + "/self/locale": { + "put": { + "description": " [internal route ID: \"change-locale\"]\n\nCalls federation service brig on send-connection-action", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LocaleUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Local Changed" + } + }, + "summary": "Change your locale." + } + }, + "/self/password": { + "head": { + "description": " [internal route ID: \"check-password-exists\"]\n\n", + "responses": { + "200": { + "description": "Password is set" + }, + "404": { + "description": "Password is not set" + } + }, + "summary": "Check that your password is set." + }, + "put": { + "description": " [internal route ID: \"change-password\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PasswordChange" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Password Changed" + }, + "403": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe user has no verified email (label: `no-identity`)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password change, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password change, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "For password change, new and old password must be different. (label: `password-must-differ`)" + } + }, + "summary": "Change your password." + } + }, + "/self/supported-protocols": { + "put": { + "description": " [internal route ID: \"change-supported-protocols\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SupportedProtocolUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Supported protocols changed" + } + }, + "summary": "Change your supported protocols" + } + }, + "/services": { + "get": { + "description": " [internal route ID: \"get-services\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "tags", + "required": false, + "schema": { + "enum": [ + "audio", + "books", + "business", + "design", + "education", + "entertainment", + "finance", + "fitness", + "food-drink", + "games", + "graphics", + "health", + "integration", + "lifestyle", + "media", + "medical", + "movies", + "music", + "news", + "photography", + "poll", + "productivity", + "quiz", + "rating", + "shopping", + "social", + "sports", + "travel", + "tutorial", + "video", + "weather" + ], + "type": "string" + } + }, + { + "in": "query", + "name": "start", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 100, + "minimum": 10, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceProfile" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Access denied. (label: `access-denied`)" + } + }, + "summary": "List services" + } + }, + "/sso/finalize-login": { + "post": { + "deprecated": true, + "description": "DEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams", + "responses": { + "200": { + "content": { + "text/plain;charset=utf-8": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/finalize-login/{team}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "team", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain;charset=utf-8": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/initiate-login/{idp}": { + "get": { + "parameters": [ + { + "in": "query", + "name": "success_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "idp", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/html": { + "schema": { + "$ref": "#/components/schemas/FormRedirect" + } + } + }, + "description": "" + } + } + }, + "head": { + "parameters": [ + { + "in": "query", + "name": "success_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "error_redirect", + "required": false, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "idp", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/plain;charset=utf-8": {} + }, + "description": "" + } + } + } + }, + "/sso/metadata": { + "get": { + "deprecated": true, + "description": "DEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams", + "responses": { + "200": { + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/metadata/{team}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "team", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/xml": { + "schema": { + "type": "string" + } + } + }, + "description": "" + } + } + } + }, + "/sso/settings": { + "get": { + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SsoSettings" + } + } + }, + "description": "" + } + } + } + }, + "/system/settings": { + "get": { + "description": " [internal route ID: \"get-system-settings\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SystemSettings" + } + } + }, + "description": "" + } + }, + "summary": "Returns a curated set of system configuration settings for authorized users." + } + }, + "/system/settings/unauthorized": { + "get": { + "description": " [internal route ID: \"get-system-settings-unauthorized\"]\n\n", + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SystemSettingsPublic" + } + } + }, + "description": "" + } + }, + "summary": "Returns a curated set of system configuration settings." + } + }, + "/teams/invitations/by-email": { + "head": { + "description": " [internal route ID: \"head-team-invitations\"]\n\n", + "parameters": [ + { + "description": "Email address", + "in": "query", + "name": "email", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Pending invitation exists." + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "No pending invitations exists." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "No pending invitations exists." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "No pending invitations exists. (label: `not-found`)" + }, + "409": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 409, + "label": "conflicting-invitations", + "message": "Multiple conflicting invitations to different teams exists." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "conflicting-invitations" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "conflicting-invitations", + "message": "Multiple conflicting invitations to different teams exists." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "conflicting-invitations" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Multiple conflicting invitations to different teams exists. (label: `conflicting-invitations`)" + } + }, + "summary": "Check if there is an invitation pending given an email address." + } + }, + "/teams/invitations/info": { + "get": { + "description": " [internal route ID: \"get-team-invitation-info\"]\n\n", + "parameters": [ + { + "description": "Invitation code", + "in": "query", + "name": "code", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "Invitation info" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `code`\n\nInvalid invitation code. (label: `invalid-invitation-code`)" + } + }, + "summary": "Get invitation info given a code." + } + }, + "/teams/notifications": { + "get": { + "description": " [internal route ID: \"get-team-notifications\"]\n\nThis is a work-around for scalability issues with gundeck user event fan-out. It does not track all team-wide events, but only `member-join`.\nNote that `/teams/notifications` behaves differently from `/notifications`:\n- If there is a gap between the notification id requested with `since` and the available data, team queues respond with 200 and the data that could be found. They do NOT respond with status 404, but valid data in the body.\n- The notification with the id given via `since` is included in the response if it exists. You should remove this and only use it to decide whether there was a gap between your last request and this one.\n- If the notification id does *not* exist, you get the more recent events from the queue (instead of all of them). This can be done because a notification id is a UUIDv1, which is essentially a time stamp.\n- There is no corresponding `/last` end-point to get only the most recent event. That end-point was only useful to avoid having to pull the entire queue. In team queues, if you have never requested the queue before and have no prior notification id, just pull with timestamp 'now'.", + "parameters": [ + { + "description": "Notification id to start with in the response (UUIDv1)", + "in": "query", + "name": "since", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Maximum number of events to return (1..10000; default: 1000)", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 10000, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QueuedNotificationList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-notification-id", + "message": "Could not parse notification id (must be UUIDv1)." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-notification-id" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `size` or `since`\n\nCould not parse notification id (must be UUIDv1). (label: `invalid-notification-id`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Team not found (label: `no-team`)" + } + }, + "summary": "Read recently added team members from team queue" + } + }, + "/teams/{team-id}/services/whitelist": { + "post": { + "description": " [internal route ID: \"post-team-whitelist-by-team-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "team-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UpdateServiceWhitelist" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "UpdateServiceWhitelistRespChanged" + }, + "204": { + "description": "UpdateServiceWhitelistRespUnchanged" + } + }, + "summary": "Update service whitelist" + } + }, + "/teams/{team-id}/services/whitelisted": { + "get": { + "description": " [internal route ID: \"get-whitelisted-services-by-team-id\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "team-id", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "query", + "name": "prefix", + "required": false, + "schema": { + "maxLength": 1, + "minLength": 128, + "type": "string" + } + }, + { + "in": "query", + "name": "filter_disabled", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 100, + "minimum": 10, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ServiceProfile" + } + } + }, + "description": "" + } + }, + "summary": "Get whitelisted services by team id" + } + }, + "/teams/{tid}": { + "delete": { + "description": " [internal route ID: \"delete-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamDeleteData" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Team is scheduled for removal" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Verification code required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "access-denied", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Verification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (missing DeleteTeam) (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + }, + "503": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 503, + "label": "queue-full", + "message": "The delete queue is full; no further delete requests can be processed at the moment" + }, + "properties": { + "code": { + "enum": [ + 503 + ], + "type": "integer" + }, + "label": { + "enum": [ + "queue-full" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The delete queue is full; no further delete requests can be processed at the moment (label: `queue-full`)" + } + }, + "summary": "Delete a team" + }, + "get": { + "description": " [internal route ID: \"get-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get a team by ID" + }, + "put": { + "description": " [internal route ID: \"update-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamUpdateData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Team updated" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions (missing SetTeamData)" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (missing SetTeamData) (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Update team properties" + } + }, + "/teams/{tid}/conversations": { + "get": { + "description": " [internal route ID: \"get-team-conversations\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamConversationList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + } + }, + "summary": "Get team conversations" + } + }, + "/teams/{tid}/conversations/roles": { + "get": { + "description": " [internal route ID: \"get-team-conversation-roles\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConversationRolesList" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get existing roles available for the given team" + } + }, + "/teams/{tid}/conversations/{cid}": { + "delete": { + "description": " [internal route ID: \"delete-team-conversation\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "cid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Conversation deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing delete_conversation) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Remove a team conversation" + }, + "get": { + "description": " [internal route ID: \"get-team-conversation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "cid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamConversation" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `cid` not found\n\nConversation not found (label: `no-conversation`)" + } + }, + "summary": "Get one team conversation" + } + }, + "/teams/{tid}/features": { + "get": { + "description": " [internal route ID: \"get-all-feature-configs-for-team\"]\n\nGets feature configs for a team. User must be a member of the team and have permission to view team features.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AllTeamFeatures" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Gets feature configs for a team" + } + }, + "/teams/{tid}/features/appLock": { + "get": { + "description": " [internal route ID: (\"get\", AppLockConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AppLockConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for appLock" + }, + "put": { + "description": " [internal route ID: (\"put\", AppLockConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AppLockConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/AppLockConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for appLock" + } + }, + "/teams/{tid}/features/classifiedDomains": { + "get": { + "description": " [internal route ID: (\"get\", ClassifiedDomainsConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClassifiedDomainsConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for classifiedDomains" + } + }, + "/teams/{tid}/features/conferenceCalling": { + "get": { + "description": " [internal route ID: (\"get\", ConferenceCallingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConferenceCallingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for conferenceCalling" + }, + "put": { + "description": " [internal route ID: (\"put\", ConferenceCallingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConferenceCallingConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ConferenceCallingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for conferenceCalling" + } + }, + "/teams/{tid}/features/conversationGuestLinks": { + "get": { + "description": " [internal route ID: (\"get\", GuestLinksConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for conversationGuestLinks" + }, + "put": { + "description": " [internal route ID: (\"put\", GuestLinksConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/GuestLinksConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for conversationGuestLinks" + } + }, + "/teams/{tid}/features/digitalSignatures": { + "get": { + "description": " [internal route ID: (\"get\", DigitalSignaturesConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DigitalSignaturesConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for digitalSignatures" + } + }, + "/teams/{tid}/features/enforceFileDownloadLocation": { + "get": { + "description": " [internal route ID: (\"get\", EnforceFileDownloadLocationConfig)]\n\n

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

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

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

", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EnforceFileDownloadLocation.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for enforceFileDownloadLocation" + } + }, + "/teams/{tid}/features/exposeInvitationURLsToTeamAdmin": { + "get": { + "description": " [internal route ID: (\"get\", ExposeInvitationURLsToTeamAdminConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for exposeInvitationURLsToTeamAdmin" + }, + "put": { + "description": " [internal route ID: (\"put\", ExposeInvitationURLsToTeamAdminConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ExposeInvitationURLsToTeamAdminConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for exposeInvitationURLsToTeamAdmin" + } + }, + "/teams/{tid}/features/fileSharing": { + "get": { + "description": " [internal route ID: (\"get\", FileSharingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FileSharingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for fileSharing" + }, + "put": { + "description": " [internal route ID: (\"put\", FileSharingConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FileSharingConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/FileSharingConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for fileSharing" + } + }, + "/teams/{tid}/features/legalhold": { + "get": { + "description": " [internal route ID: (\"get\", LegalholdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LegalholdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for legalhold" + }, + "put": { + "description": " [internal route ID: (\"put\", LegalholdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LegalholdConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LegalholdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-disable-unimplemented", + "message": "legal hold cannot be disabled for whitelisted teams" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-disable-unimplemented", + "legalhold-not-enabled", + "too-large-team-for-legalhold", + "action-denied", + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nCannot enable legalhold on large teams (reason: for removing LH from team, we need to iterate over all members, which is only supported for teams with less than 2k members) (label: `too-large-team-for-legalhold`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Put config for legalhold" + } + }, + "/teams/{tid}/features/limitedEventFanout": { + "get": { + "description": " [internal route ID: (\"get\", LimitedEventFanoutConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LimitedEventFanoutConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for limitedEventFanout" + } + }, + "/teams/{tid}/features/mls": { + "get": { + "description": " [internal route ID: (\"get\", MLSConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for mls" + }, + "put": { + "description": " [internal route ID: (\"put\", MLSConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MLSConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for mls" + } + }, + "/teams/{tid}/features/mlsE2EId": { + "get": { + "description": " [internal route ID: (\"get\", MlsE2EIdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsE2EIdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for mlsE2EId" + }, + "put": { + "description": " [internal route ID: (\"put\", MlsE2EIdConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsE2EIdConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsE2EIdConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for mlsE2EId" + } + }, + "/teams/{tid}/features/mlsMigration": { + "get": { + "description": " [internal route ID: (\"get\", MlsMigrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsMigration.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for mlsMigration" + }, + "put": { + "description": " [internal route ID: (\"put\", MlsMigrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsMigration.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/MlsMigration.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for mlsMigration" + } + }, + "/teams/{tid}/features/outlookCalIntegration": { + "get": { + "description": " [internal route ID: (\"get\", OutlookCalIntegrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for outlookCalIntegration" + }, + "put": { + "description": " [internal route ID: (\"put\", OutlookCalIntegrationConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/OutlookCalIntegrationConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for outlookCalIntegration" + } + }, + "/teams/{tid}/features/searchVisibility": { + "get": { + "description": " [internal route ID: (\"get\", SearchVisibilityAvailableConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for searchVisibility" + }, + "put": { + "description": " [internal route ID: (\"put\", SearchVisibilityAvailableConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityAvailableConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for searchVisibility" + } + }, + "/teams/{tid}/features/searchVisibilityInbound": { + "get": { + "description": " [internal route ID: (\"get\", SearchVisibilityInboundConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for searchVisibilityInbound" + }, + "put": { + "description": " [internal route ID: (\"put\", SearchVisibilityInboundConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchVisibilityInboundConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for searchVisibilityInbound" + } + }, + "/teams/{tid}/features/selfDeletingMessages": { + "get": { + "description": " [internal route ID: (\"get\", SelfDeletingMessagesConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for selfDeletingMessages" + }, + "put": { + "description": " [internal route ID: (\"put\", SelfDeletingMessagesConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SelfDeletingMessagesConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for selfDeletingMessages" + } + }, + "/teams/{tid}/features/sndFactorPasswordChallenge": { + "get": { + "description": " [internal route ID: (\"get\", SndFactorPasswordChallengeConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for sndFactorPasswordChallenge" + }, + "put": { + "description": " [internal route ID: (\"put\", SndFactorPasswordChallengeConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.Feature" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SndFactorPasswordChallengeConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Put config for sndFactorPasswordChallenge" + } + }, + "/teams/{tid}/features/sso": { + "get": { + "description": " [internal route ID: (\"get\", SSOConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SSOConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for sso" + } + }, + "/teams/{tid}/features/validateSAMLemails": { + "get": { + "description": " [internal route ID: (\"get\", ValidateSAMLEmailsConfig)]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ValidateSAMLEmailsConfig.LockableFeature" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Get config for validateSAMLemails" + } + }, + "/teams/{tid}/get-members-by-ids-using-post": { + "post": { + "description": " [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Maximum results to be returned", + "in": "query", + "name": "maxResults", + "required": false, + "schema": { + "format": "int32", + "maximum": 2000, + "minimum": 1, + "type": "integer" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserIdList" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMemberList" + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "too-many-uids", + "message": "Can only process 2000 user ids per request." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-uids" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body` or `maxResults`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get team members by user id list" + } + }, + "/teams/{tid}/invitations": { + "get": { + "description": " [internal route ID: \"get-team-invitations\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Invitation id to start from (ascending).", + "in": "query", + "name": "start", + "required": false, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Number of results to return (default 100, max 500).", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/InvitationList" + } + } + }, + "description": "List of sent invitations" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + } + }, + "summary": "List the sent team invitations" + }, + "post": { + "description": " [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/InvitationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "Invitation was created and sent.", + "headers": { + "Location": { + "schema": { + "format": "url", + "type": "string" + } + } + } + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions", + "too-many-team-invitations", + "blacklisted-email", + "no-identity", + "no-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified email (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)" + } + }, + "summary": "Create and send a new team invitation." + } + }, + "/teams/{tid}/invitations/{iid}": { + "delete": { + "description": " [internal route ID: \"delete-team-invitation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "iid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Invitation deleted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + } + }, + "summary": "Delete a pending team invitation by ID." + }, + "get": { + "description": " [internal route ID: \"get-team-invitation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "iid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "Invitation" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Notification not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Notification not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `iid` or Notification not found. (label: `not-found`)" + } + }, + "summary": "Get a pending team invitation by ID." + } + }, + "/teams/{tid}/legalhold/consent": { + "post": { + "description": " [internal route ID: \"consent-to-legal-hold\"]\n\nCalls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Grant consent successful" + }, + "204": { + "description": "Consent already granted" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam member not found (label: `no-team-member`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Consent to legal hold" + } + }, + "/teams/{tid}/legalhold/settings": { + "delete": { + "description": " [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RemoveLegalHoldSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Legal hold service settings deleted" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-disable-unimplemented", + "message": "legal hold cannot be disabled for whitelisted teams" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-disable-unimplemented", + "legalhold-not-enabled", + "invalid-op", + "action-denied", + "no-team-member", + "operation-denied", + "code-authentication-required", + "code-authentication-failed", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Delete legal hold service settings" + }, + "get": { + "description": " [internal route ID: \"get-legal-hold-settings\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ViewLegalHoldService" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get legal hold service settings" + }, + "post": { + "description": " [internal route ID: \"create-legal-hold-settings\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewLegalHoldService" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewLegalHoldService" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ViewLegalHoldService" + } + } + }, + "description": "Legal hold service settings created" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-status-bad", + "message": "legal hold service: invalid response" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-status-bad", + "legalhold-invalid-key" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Create legal hold service settings" + } + }, + "/teams/{tid}/legalhold/{uid}": { + "delete": { + "description": " [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/DisableLegalHoldForUserRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Disable legal hold successful" + }, + "204": { + "description": "Legal hold was not enabled" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "action-denied", + "code-authentication-required", + "code-authentication-failed", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Disable legal hold for user" + }, + "get": { + "description": " [internal route ID: \"get-legal-hold\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserLegalHoldStatusResponse" + } + } + }, + "description": "" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)" + } + }, + "summary": "Get legal hold status" + }, + "post": { + "description": " [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Request device successful" + }, + "204": { + "description": "Request device already pending" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered", + "legalhold-status-bad" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "operation-denied", + "no-team-member", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "legalhold-no-consent", + "message": "user has not given consent to using legal hold" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-no-consent", + "legalhold-already-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "user has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-illegal-op", + "message": "internal server error: inconsistent change of user's legalhold state" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-illegal-op", + "legalhold-internal" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)" + } + }, + "summary": "Request legal hold device" + } + }, + "/teams/{tid}/legalhold/{uid}/approve": { + "put": { + "description": " [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)
Calls federation service brig on get-users-by-ids
Calls federation service galley on on-mls-message-sent
Calls federation service galley on on-conversation-updated", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ApproveLegalHoldForUserRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Legal hold approved" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "legalhold-not-registered", + "message": "legal hold service has not been registered for this team" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-registered" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid `body`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "no-team-member", + "action-denied", + "access-denied", + "code-authentication-required", + "code-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "legalhold-no-device-allocated", + "message": "no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-no-device-allocated" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nno legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)" + }, + "409": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 409, + "label": "legalhold-already-enabled", + "message": "legal hold is already enabled for this user" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-already-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold is already enabled for this user (label: `legalhold-already-enabled`)" + }, + "412": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 412, + "label": "legalhold-not-pending", + "message": "legal hold cannot be approved without being in a pending state" + }, + "properties": { + "code": { + "enum": [ + 412 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-pending" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)" + }, + "500": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)" + } + }, + "summary": "Approve legal hold device" + } + }, + "/teams/{tid}/members": { + "get": { + "description": " [internal route ID: \"get-team-members\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Maximum results to be returned", + "in": "query", + "name": "maxResults", + "required": false, + "schema": { + "format": "int32", + "maximum": 2000, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.", + "in": "query", + "name": "pagingState", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMembersPage" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Get team members" + }, + "put": { + "description": " [internal route ID: \"update-team-member\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/NewTeamMember" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "too-many-team-admins", + "invalid-permissions", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Update an existing team member" + } + }, + "/teams/{tid}/members/csv": { + "get": { + "description": " [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "text/csv": {} + }, + "description": "CSV of team members" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "You do not have permission to access this resource" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "You do not have permission to access this resource (label: `access-denied`)" + } + }, + "summary": "Get all members of the team as a CSV file" + } + }, + "/teams/{tid}/members/{uid}": { + "delete": { + "description": " [internal route ID: \"delete-team-member\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMemberDeleteData" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "202": { + "description": "Team member scheduled for deletion" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "access-denied", + "code-authentication-required", + "code-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)" + } + }, + "summary": "Remove an existing team member" + }, + "get": { + "description": " [internal route ID: \"get-team-member\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamMember" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Requesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` or `uid` not found\n\nTeam member not found (label: `no-team-member`)" + } + }, + "summary": "Get single team member" + } + }, + "/teams/{tid}/search": { + "get": { + "description": " [internal route ID: \"browse-team\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "Search expression", + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string" + } + }, + { + "description": "Role filter, eg. `member,partner`. Empty list means do not filter.", + "in": "query", + "name": "frole", + "required": false, + "schema": { + "items": { + "enum": [ + "owner", + "admin", + "member", + "partner" + ], + "type": "string" + }, + "type": "array" + } + }, + { + "description": "Can be one of name, handle, email, saml_idp, managed_by, role, created_at.", + "in": "query", + "name": "sortby", + "required": false, + "schema": { + "enum": [ + "name", + "handle", + "email", + "saml_idp", + "managed_by", + "role", + "created_at" + ], + "type": "string" + } + }, + { + "description": "Can be one of asc, desc.", + "in": "query", + "name": "sortorder", + "required": false, + "schema": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + } + }, + { + "description": "Number of results to return (min: 1, max: 500, default: 15)", + "in": "query", + "name": "size", + "required": false, + "schema": { + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.", + "in": "query", + "name": "pagingState", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SearchResult" + } + } + }, + "description": "Search results" + } + }, + "summary": "Browse team for members (requires add-user permission)" + } + }, + "/teams/{tid}/search-visibility": { + "get": { + "description": " [internal route ID: \"get-search-visibility\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamSearchVisibilityView" + } + } + }, + "description": "" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + } + }, + "summary": "Shows the value for search visibility" + }, + "put": { + "description": " [internal route ID: \"set-search-visibility\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamSearchVisibilityView" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Search visibility set" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "team-search-visibility-not-enabled", + "message": "Custom search is not available for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "team-search-visibility-not-enabled", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`tid` not found\n\nTeam not found (label: `no-team`)" + } + }, + "summary": "Sets the search visibility for the whole team" + } + }, + "/teams/{tid}/size": { + "get": { + "description": " [internal route ID: \"get-team-size\"]\n\nCan be out of sync by roughly the `refresh_interval` of the ES index.", + "parameters": [ + { + "in": "path", + "name": "tid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamSize" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/TeamSize" + } + } + }, + "description": "Number of team members" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Invalid invitation code. (label: `invalid-invitation-code`)" + } + }, + "summary": "Get the number of team members as an integer" + } + }, + "/users/handles": { + "post": { + "description": " [internal route ID: \"check-user-handles\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/CheckHandles" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Handle" + }, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/Handle" + }, + "type": "array" + } + } + }, + "description": "List of free handles" + } + }, + "summary": "Check availability of user handles" + } + }, + "/users/handles/{handle}": { + "head": { + "description": " [internal route ID: \"check-user-handle\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "handle", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "Handle is taken" + }, + "400": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 400, + "label": "invalid-handle", + "message": "The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist)" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-handle" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "The given handle is invalid (less than 2 or more than 256 characters; chars not in \"a-z0-9_.-\"; or on the blocklist) (label: `invalid-handle`)" + }, + "404": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Handle not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`handle` not found\n\nHandle not found (label: `not-found`)" + } + }, + "summary": "Check whether a user handle can be taken" + } + }, + "/users/list-clients": { + "post": { + "description": " [internal route ID: \"list-clients-bulk@v2\"]\n\nIf a backend is unreachable, the clients from that backend will be omitted from the responseCalls federation service brig on get-user-clients", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/LimitedQualifiedUserIdList_500" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "properties": { + "qualified_user_map": { + "$ref": "#/components/schemas/QualifiedUserMap_Set_PubClient" + } + }, + "type": "object" + } + } + }, + "description": "" + } + }, + "summary": "List all clients for a set of user ids" + } + }, + "/users/list-prekeys": { + "post": { + "description": " [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\nYou can't request information for more users than maximum conversation size.Calls federation service brig on claim-multi-prekey-bundle", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QualifiedUserClients" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/QualifiedUserClientPrekeyMapV4" + } + } + }, + "description": "" + } + }, + "summary": "(deprecated) Given a map of user IDs to client IDs return a prekey for each one." + } + }, + "/users/{uid_domain}/{uid}": { + "get": { + "description": " [internal route ID: \"get-user-qualified\"]\n\nCalls federation service brig on get-users-by-ids", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserProfile" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/UserProfile" + } + } + }, + "description": "User found" + }, + "404": { + "content": { + "application/json": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "`uid_domain` or `uid` or User not found (label: `not-found`)" + } + }, + "summary": "Get a user by Domain and UserId" + } + }, + "/users/{uid_domain}/{uid}/clients": { + "get": { + "description": " [internal route ID: \"get-user-clients-qualified\"]\n\nCalls federation service brig on get-user-clients", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/PubClient" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "summary": "Get all of a user's clients" + } + }, + "/users/{uid_domain}/{uid}/clients/{client}": { + "get": { + "description": " [internal route ID: \"get-user-client-qualified\"]\n\nCalls federation service brig on get-user-clients", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PubClient" + } + } + }, + "description": "" + } + }, + "summary": "Get a specific client of a user" + } + }, + "/users/{uid_domain}/{uid}/prekeys": { + "get": { + "description": " [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\nCalls federation service brig on claim-prekey-bundle", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/PrekeyBundle" + } + } + }, + "description": "" + } + }, + "summary": "Get a prekey for each client of a user." + } + }, + "/users/{uid_domain}/{uid}/prekeys/{client}": { + "get": { + "description": " [internal route ID: \"get-users-prekeys-client-qualified\"]\n\nCalls federation service brig on claim-prekey", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/ClientPrekey" + } + } + }, + "description": "" + } + }, + "summary": "Get a prekey for a specific client of a user." + } + }, + "/users/{uid_domain}/{uid}/supported-protocols": { + "get": { + "description": " [internal route ID: \"get-supported-protocols\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array", + "uniqueItems": true + } + }, + "application/json;charset=utf-8": { + "schema": { + "items": { + "$ref": "#/components/schemas/BaseProtocol" + }, + "type": "array", + "uniqueItems": true + } + } + }, + "description": "Protocols supported by the user" + } + }, + "summary": "Get a user's supported protocols" + } + }, + "/users/{uid}/email": { + "put": { + "description": " [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.", + "parameters": [ + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/EmailUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + } + }, + "description": "" + } + }, + "summary": "Resend email address validation email." + } + }, + "/users/{uid}/rich-info": { + "get": { + "description": " [internal route ID: \"get-rich-info\"]\n\n", + "parameters": [ + { + "description": "User Id", + "in": "path", + "name": "uid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RichInfoAssocList" + } + }, + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/RichInfoAssocList" + } + } + }, + "description": "Rich info about the user" + }, + "403": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "description": "Insufficient team permissions (label: `insufficient-permissions`)" + } + }, + "summary": "Get a user's rich info" + } + }, + "/verification-code/send": { + "post": { + "description": " [internal route ID: \"send-verification-code\"]\n\n", + "requestBody": { + "content": { + "application/json;charset=utf-8": { + "schema": { + "$ref": "#/components/schemas/SendVerificationCode" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Verification code sent." + } + }, + "summary": "Send a verification code to a given email address." + } + } + }, + "security": [ + { + "ZAuth": [] + } + ], + "servers": [ + { + "url": "/v6" + } + ] +} diff --git a/services/brig/src/Brig/API/Auth.hs b/services/brig/src/Brig/API/Auth.hs index b47366621fc..5051540aacd 100644 --- a/services/brig/src/Brig/API/Auth.hs +++ b/services/brig/src/Brig/API/Auth.hs @@ -55,6 +55,7 @@ import Wire.AuthenticationSubsystem.Config import Wire.AuthenticationSubsystem.Error import Wire.AuthenticationSubsystem.ZAuth import Wire.BlockListStore +import Wire.BudgetStore import Wire.ClientStore (ClientStore) import Wire.DomainRegistrationStore (DomainRegistrationStore) import Wire.EmailSubsystem (EmailSubsystem) @@ -133,7 +134,8 @@ sendLoginCode _ = throwStd (errorToWai @'E.InvalidPhone) login :: - ( Member TinyLog r, + ( Member BudgetStore r, + Member TinyLog r, Member UserKeyStore r, Member UserStore r, Member Events r, diff --git a/services/brig/src/Brig/API/Error.hs b/services/brig/src/Brig/API/Error.hs index 733550000c8..c38f0d94f0a 100644 --- a/services/brig/src/Brig/API/Error.hs +++ b/services/brig/src/Brig/API/Error.hs @@ -30,6 +30,7 @@ import Wire.API.Federation.Error import Wire.API.User import Wire.AuthenticationSubsystem.Error import Wire.Error +import Wire.JwtTools (CertEnrollmentError (..)) throwStd :: (MonadError HttpError m) => Wai.Error -> m a throwStd = throwError . StdError diff --git a/services/brig/src/Brig/API/Federation.hs b/services/brig/src/Brig/API/Federation.hs index 3641348b490..d109db22531 100644 --- a/services/brig/src/Brig/API/Federation.hs +++ b/services/brig/src/Brig/API/Federation.hs @@ -72,6 +72,7 @@ import Wire.Error import Wire.FederationConfigStore (FederationConfigStore) import Wire.FederationConfigStore qualified as E import Wire.GalleyAPIAccess (GalleyAPIAccess) +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) import Wire.NotificationSubsystem import Wire.Sem.Concurrency import Wire.UserStore @@ -89,6 +90,7 @@ federationSitemap :: Member UserSubsystem r, Member UserStore r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, Member ClientSubsystem r ) => ServerT FederationAPI (Handler r) @@ -203,7 +205,8 @@ claimMultiPrekeyBundle _ uc = lift $ liftSem $ ClientSubsystem.claimLocalMultiPr fedClaimKeyPackages :: ( Member GalleyAPIAccess r, Member UserStore r, - Member ClientStore r + Member ClientStore r, + Member MlsKeyPackageSubsystem r ) => Domain -> ClaimKeyPackageRequest -> @@ -283,15 +286,15 @@ searchUsers domain (SearchRequest searchTerm mTeam mOnlyInTeams mbUserTypeFilter getUserClients :: (Member ClientSubsystem r) => Domain -> GetUserClients -> (Handler r) (UserMap (Set PubClient)) getUserClients _ (GetUserClients uids) = lift (liftSem $ ClientSubsystem.lookupLocalPublicClientsBulk uids) !>> clientErrorToHttpError -getMLSClients :: (Member ClientStore r) => Domain -> MLSClientsRequest -> Handler r (Set ClientInfo) +getMLSClients :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Domain -> MLSClientsRequest -> Handler r (Set ClientInfo) getMLSClients _domain mcr = do Internal.getMLSClientsH mcr.userId mcr.cipherSuite -getMLSClient :: (Member ClientStore r) => Domain -> MLSClientRequest -> Handler r ClientInfo +getMLSClient :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Domain -> MLSClientRequest -> Handler r ClientInfo getMLSClient _domain mcr = Internal.getMLSClientH mcr.userId mcr.clientId mcr.cipherSuite -getMLSClientsV0 :: (Member ClientStore r) => Domain -> MLSClientsRequestV0 -> Handler r (Set ClientInfo) +getMLSClientsV0 :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Domain -> MLSClientsRequestV0 -> Handler r (Set ClientInfo) getMLSClientsV0 domain mcr0 = getMLSClients domain (mlsClientsRequestFromV0 mcr0) onUserDeleted :: diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 6a6f17dbb80..86f9f2e9b2d 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -34,8 +34,6 @@ import Brig.API.User qualified as API import Brig.App as App import Brig.Data.Activation import Brig.Data.Connection qualified as Data -import Brig.Data.MLS.KeyPackage qualified as Data -import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Options hiding (internalEvents) import Brig.Provider.API qualified as Provider import Brig.Team.API qualified as Team @@ -90,6 +88,7 @@ import Wire.API.UserGroup (UserGroup) import Wire.API.UserGroup.Pagination import Wire.API.UserMap import Wire.ActivationCodeStore (ActivationCodeStore) +import Wire.ActivationCodeStore qualified as ActivationCode import Wire.AppStore (AppStore) import Wire.AppStore qualified as AppStore import Wire.AppSubsystem (AppSubsystem) @@ -118,6 +117,8 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore (IndexedUserStore, getTeamSize) import Wire.InvitationStore +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem qualified as Mls import Wire.NotificationSubsystem import Wire.PasswordResetCodeStore (PasswordResetCodeStore) import Wire.PropertySubsystem @@ -128,10 +129,12 @@ import Wire.Sem.Concurrency import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import Wire.SparAPIAccess (SparAPIAccess) +import Wire.StoredUser (StoredUser (..)) import Wire.TeamInvitationSubsystem import Wire.TeamSubsystem (TeamSubsystem) import Wire.UserGroupSubsystem import Wire.UserKeyStore +import Wire.UserPendingActivationStore (UserPendingActivationStore) import Wire.UserStore as UserStore import Wire.UserSubsystem import Wire.UserSubsystem qualified as User @@ -186,7 +189,8 @@ servantSitemap :: Member AppStore r, Member AppSubsystem r, Member ClientStore r, - Member ClientSubsystem r + Member ClientSubsystem r, + Member MlsKeyPackageSubsystem r ) => ServerT BrigIRoutes.API (Handler r) servantSitemap = @@ -222,7 +226,7 @@ ejpdAPI :: ServerT BrigIRoutes.EJPDRequest (Handler r) ejpdAPI = Named @"ejpd-request" Brig.User.EJPD.ejpdRequest -mlsAPI :: (Member ClientStore r) => ServerT BrigIRoutes.MLSAPI (Handler r) +mlsAPI :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => ServerT BrigIRoutes.MLSAPI (Handler r) mlsAPI = Named @"get-mls-clients" getMLSClientsH :<|> Named @"get-mls-client" getMLSClientH @@ -282,11 +286,11 @@ accountAPI = :<|> Named @"iPutUserSsoId" updateSSOIdH :<|> Named @"iDeleteUserSsoId" deleteSSOIdH :<|> Named @"iPutManagedBy" updateManagedByH + :<|> Named @"iDeletePendingEmailUpdate" deletePendingEmailUpdateH :<|> Named @"iPutRichInfo" updateRichInfoH :<|> Named @"iPutHandle" updateHandleH :<|> Named @"iPutUserName" updateUserNameH :<|> Named @"iGetRichInfo" getRichInfoH - :<|> Named @"iGetRichInfoMulti" getRichInfoMultiH :<|> Named @"iHeadHandle" checkHandleInternalH :<|> Named @"iConnectionUpdate" updateConnectionInternalH :<|> Named @"iListClients" internalListClientsH @@ -458,13 +462,13 @@ deleteAccountConferenceCallingConfig :: (Member UserStore r) => UserId -> Handle deleteAccountConferenceCallingConfig uid = lift . liftSem $ UserStore.updateFeatureConferenceCalling uid Nothing $> NoContent -getMLSClientH :: (Member ClientStore r) => UserId -> ClientId -> CipherSuite -> Handler r ClientInfo +getMLSClientH :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => UserId -> ClientId -> CipherSuite -> Handler r ClientInfo getMLSClientH usr cid suite = do lusr <- qualifyLocal usr suiteTag <- maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag suite) lift $ getMLSClient lusr cid suiteTag -getMLSClientsH :: (Member ClientStore r) => UserId -> CipherSuite -> Handler r (Set ClientInfo) +getMLSClientsH :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => UserId -> CipherSuite -> Handler r (Set ClientInfo) getMLSClientsH usr suite = do lusr <- qualifyLocal usr suiteTag <- maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag suite) @@ -473,13 +477,13 @@ getMLSClientsH usr suite = do pure $ Set.fromList clientInfos getMLSClient :: - (Member ClientStore r) => + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CipherSuiteTag -> AppT r ClientInfo getMLSClient lusr cid suiteTag = do - numKeyPackages <- wrapClient $ Data.countKeyPackages lusr cid suiteTag + numKeyPackages <- liftSem $ Mls.countMlsKeyPackages (tUnqualified lusr) cid suiteTag mc <- liftSem $ ClientStore.lookupClient (tUnqualified lusr) cid let keys = foldMap (.clientMLSPublicKeys) mc ss = csSignatureScheme suiteTag @@ -625,6 +629,7 @@ createUserNoVerifySpar uData = deleteUserNoAuthH :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member TinyLog r, Member UserKeyStore r, @@ -898,6 +903,20 @@ updateManagedByH :: (Member UserStore r) => UserId -> ManagedByUpdate -> (Handle updateManagedByH uid (ManagedByUpdate managedBy) = do NoContent <$ lift (liftSem $ UserStore.updateManagedBy uid managedBy) +deletePendingEmailUpdateH :: + ( Member UserStore r, + Member ActivationCodeStore r + ) => + UserId -> + (Handler r) NoContent +deletePendingEmailUpdateH uid = do + mUser <- lift . liftSem $ UserStore.getUser uid + for_ (emailUnvalidated =<< mUser) $ \email -> + lift . liftSem $ do + ActivationCode.deleteActivationCode (mkEmailKey email) + UserStore.deleteEmailUnvalidated uid + pure NoContent + updateRichInfoH :: (Member UserStore r) => UserId -> RichInfoUpdate -> (Handler r) NoContent updateRichInfoH uid rup = NoContent <$ do @@ -947,10 +966,6 @@ getRichInfoH uid = RichInfo . fromMaybe mempty <$> lift (liftSem $ UserStore.getRichInfo uid) -getRichInfoMultiH :: (Member UserStore r) => Maybe (CommaSeparatedList UserId) -> Handler r BrigIRoutes.GetRichInfoMultiResponse -getRichInfoMultiH (maybe [] fromCommaSeparatedList -> uids) = - lift $ liftSem $ BrigIRoutes.GetRichInfoMultiResponse <$> UserStore.lookupRichInfos uids - updateHandleH :: (Member UserSubsystem r) => UserId -> @@ -1049,7 +1064,7 @@ deleteGroupManagedInternalH tid gid managedBy = do pure NoContent deleteAppH :: (Member AppSubsystem r) => TeamId -> UserId -> Handler r NoContent -deleteAppH tid uid = lift . liftSem $ AppSubsystem.deleteApp tid uid >> pure NoContent +deleteAppH tid uid = lift . liftSem $ AppSubsystem.internalDeleteApp tid uid >> pure NoContent getAppIdsH :: (Member AppStore r) => TeamId -> Handler r [UserId] getAppIdsH tid = lift . liftSem $ map (.id) <$> AppStore.getApps tid diff --git a/services/brig/src/Brig/API/MLS/KeyPackages.hs b/services/brig/src/Brig/API/MLS/KeyPackages.hs index 08f1f727caf..26c94b8daba 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages.hs @@ -35,7 +35,6 @@ import Brig.API.MLS.KeyPackages.Validation import Brig.API.MLS.Util import Brig.API.Types import Brig.App -import Brig.Data.MLS.KeyPackage qualified as Data import Control.Monad.Trans.Except import Control.Monad.Trans.Maybe import Data.CommaSeparatedList @@ -60,20 +59,23 @@ import Wire.ClientSubsystem.Error import Wire.FederationAPIAccess import Wire.GalleyAPIAccess (GalleyAPIAccess, getUserLegalholdStatus) import Wire.GalleyAPIAccess qualified as GalleyAPIAccess +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem qualified as Mls import Wire.StoredUser import Wire.UserStore (UserStore, getUser) -uploadKeyPackages :: (Member ClientStore r) => Local UserId -> ClientId -> KeyPackageUpload -> Handler r () +uploadKeyPackages :: (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> KeyPackageUpload -> Handler r () uploadKeyPackages lusr cid kps = do assertMLSEnabled let identity = mkClientIdentity (tUntagged lusr) cid kps' <- traverse (validateUploadedKeyPackage identity) kps.keyPackages - lift . wrapClient $ Data.insertKeyPackages (tUnqualified lusr) cid kps' + lift $ liftSem $ Mls.insertMlsKeyPackages (tUnqualified lusr) cid kps' claimKeyPackages :: ( Member GalleyAPIAccess r, Member UserStore r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, HasBrigFederationAccess m r ) => Local UserId -> @@ -87,6 +89,7 @@ claimKeyPackagesV7 :: ( Member GalleyAPIAccess r, Member UserStore r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, HasBrigFederationAccess m r ) => Local UserId -> @@ -108,7 +111,8 @@ claimLocalKeyPackages :: forall r. ( Member GalleyAPIAccess r, Member UserStore r, - Member ClientStore r + Member ClientStore r, + Member MlsKeyPackageSubsystem r ) => Qualified UserId -> Maybe ClientId -> @@ -118,7 +122,15 @@ claimLocalKeyPackages :: claimLocalKeyPackages qusr skipOwn suite qTarget = do let target = tUnqualified qTarget su <- lift (liftSem $ getUser target) >>= maybe (throwE (ClientUserNotFound target)) pure - when (not su.activated || maybe True ((/=) Active) su.status) $ throwE (ClientUserNotFound target) + case (su.activated, su.status) of + (True, Just Active) -> pure () + -- an ephemeral user is identity-less and won't get activated + (_, Just Ephemeral) -> pure () + (False, _) -> throwE $ ClientUserNotFound target + (True, Nothing) -> throwE $ ClientUserNotFound target + (True, Just Deleted) -> throwE $ ClientUserNotFound target + (True, Just Suspended) -> throwE $ ClientUserNotFound target + (True, Just PendingInvitation) -> throwE $ ClientUserNotFound target -- while we do not support federation + MLS together with legalhold, to make sure that -- the remote backend is complicit with our legalhold policies, we disallow anyone -- fetching key packages for users under legalhold @@ -149,7 +161,7 @@ claimLocalKeyPackages qusr skipOwn suite qTarget = do runMaybeT $ do guard $ Just c /= own uncurry (KeyPackageBundleEntry (tUntagged qTarget) c) - <$> wrapClientM (Data.claimKeyPackage qTarget c suite) + <$> MaybeT (liftSem $ Mls.claimMlsKeyPackage (tUnqualified qTarget) c suite) -- FUTUREWORK: shouldn't this be defined elsewhere for general use? assertUserNotUnderLegalHold :: StoredUser -> ExceptT ClientError (AppT r) () @@ -203,18 +215,19 @@ claimRemoteKeyPackages lusr suite target = do pure bundle -countKeyPackages :: Local UserId -> ClientId -> CipherSuite -> Handler r KeyPackageCount +countKeyPackages :: (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CipherSuite -> Handler r KeyPackageCount countKeyPackages lusr c = countKeyPackagesV7 lusr c . Just -countKeyPackagesV7 :: Local UserId -> ClientId -> Maybe CipherSuite -> Handler r KeyPackageCount +countKeyPackagesV7 :: (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> Maybe CipherSuite -> Handler r KeyPackageCount countKeyPackagesV7 lusr c mSuite = do assertMLSEnabled suite <- getCipherSuite mSuite lift $ KeyPackageCount . fromIntegral - <$> wrapClient (Data.countKeyPackages lusr c suite) + <$> liftSem (Mls.countMlsKeyPackages (tUnqualified lusr) c suite) deleteKeyPackages :: + (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CipherSuite -> @@ -223,6 +236,7 @@ deleteKeyPackages :: deleteKeyPackages lusr c = deleteKeyPackagesV7 lusr c . Just deleteKeyPackagesV7 :: + (Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> Maybe CipherSuite -> @@ -231,10 +245,10 @@ deleteKeyPackagesV7 :: deleteKeyPackagesV7 lusr c mSuite (unDeleteKeyPackages -> refs) = do assertMLSEnabled suite <- getCipherSuite mSuite - lift $ wrapClient (Data.deleteKeyPackages (tUnqualified lusr) c suite refs) + lift $ liftSem (Mls.deleteMlsKeyPackages (tUnqualified lusr) c suite refs) replaceKeyPackages :: - (Member ClientStore r) => + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> CommaSeparatedList CipherSuite -> @@ -243,7 +257,7 @@ replaceKeyPackages :: replaceKeyPackages lusr c = replaceKeyPackagesV7 lusr c . Just replaceKeyPackagesV7 :: - (Member ClientStore r) => + (Member ClientStore r, Member MlsKeyPackageSubsystem r) => Local UserId -> ClientId -> Maybe (CommaSeparatedList CipherSuite) -> @@ -252,5 +266,5 @@ replaceKeyPackagesV7 :: replaceKeyPackagesV7 lusr c (fmap toList -> mSuites) upload = do assertMLSEnabled suites <- validateCipherSuites mSuites upload - lift $ wrapClient (Data.deleteAllKeyPackages (tUnqualified lusr) c suites) + lift $ liftSem (Mls.deleteAllMlsKeyPackages (tUnqualified lusr) c (toList suites)) uploadKeyPackages lusr c upload diff --git a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs index 45173c9eb66..62d5bd2b8cd 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs @@ -46,6 +46,7 @@ import Wire.API.MLS.Validation import Wire.API.MLS.Validation.Error (toText) import Wire.ClientStore (ClientStore) import Wire.ClientStore qualified as ClientStore +import Wire.MlsKeyPackageSubsystem qualified as Mls validateUploadedKeyPackage :: (Member ClientStore r) => @@ -91,14 +92,7 @@ validateLifetime lt = do validateLifetime' now mMaxLifetime lt validateLifetime' :: POSIXTime -> Maybe NominalDiffTime -> Lifetime -> Either Text () -validateLifetime' now mMaxLifetime lt = do - when (tsPOSIX (ltNotBefore lt) > now) $ - Left "Key package not_before date is in the future" - when (tsPOSIX (ltNotAfter lt) <= now) $ - Left "Key package is expired" - for_ mMaxLifetime $ \maxLifetime -> - when (tsPOSIX (ltNotAfter lt) > now + maxLifetime) $ - Left "Key package expiration time is too far in the future" +validateLifetime' = Mls.validateKeyPackageLifetime mlsProtocolErrorFromValidationError :: ValidationError -> Handler r a mlsProtocolErrorFromValidationError InvalidLeafNodeSignature = throwStd (errorToWai @E.MLSInvalidLeafNodeSignature) diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 8173ad1568c..dd5471760b1 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -1,6 +1,7 @@ {-# LANGUAGE PartialTypeSignatures #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TemplateHaskell #-} +{-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-partial-type-signatures #-} -- This file is part of the Wire Server implementation. @@ -33,6 +34,7 @@ import Brig.API.Connection qualified as API import Brig.API.Error import Brig.API.Handler import Brig.API.MLS.KeyPackages +import Brig.API.MLS.Util (isMLSEnabled) import Brig.API.OAuth (oauthAPI) import Brig.API.Public.Swagger import Brig.API.Types @@ -43,10 +45,6 @@ import Brig.Calling.API qualified as Calling import Brig.Data.Connection qualified as Data import Brig.Data.Nonce as Nonce import Brig.Effects.ConnectionStore -import Brig.Effects.JwtTools (JwtTools) -import Brig.Effects.PublicKeyBundle (PublicKeyBundle) -import Brig.Effects.SFT -import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Options hiding (internalEvents) import Brig.Provider.API import Brig.Team.API qualified as Team @@ -57,7 +55,7 @@ import Brig.User.Client qualified as API import Cassandra qualified as C import Cassandra qualified as Data import Control.Error hiding (bool, note) -import Control.Lens ((.~), (?~)) +import Control.Lens ((?~)) import Control.Monad.Except import Data.Aeson import Data.ByteString (fromStrict) @@ -81,6 +79,7 @@ import Data.OpenApi qualified as S import Data.Qualified import Data.Range import Data.Schema () +import Data.Set qualified as Set import Data.Text.Encoding qualified as Text import Data.ZAuth.CryptoSign (CryptoSign) import Data.ZAuth.Token qualified as ZAuth @@ -101,6 +100,7 @@ import Servant.Swagger.UI import System.Logger.Class qualified as Log import Util.Logging (logFunction, logHandle, logTeam, logUser) import Wire.API.Connection qualified as Public +import Wire.API.Conversation.Protocol (ProtocolTag (..)) import Wire.API.EnterpriseLogin import Wire.API.Error import Wire.API.Error.Brig qualified as E @@ -111,7 +111,6 @@ import Wire.API.Federation.Error import Wire.API.Federation.Version qualified as Fed import Wire.API.Pagination import Wire.API.Properties qualified as Public -import Wire.API.Routes.API import Wire.API.Routes.Bearer import Wire.API.Routes.Internal.Brig qualified as BrigInternalAPI import Wire.API.Routes.Internal.Cannon qualified as CannonInternalAPI @@ -123,18 +122,13 @@ import Wire.API.Routes.MultiTablePaging qualified as Public import Wire.API.Routes.Named (Named (Named)) import Wire.API.Routes.Public.Brig import Wire.API.Routes.Public.Brig.DomainVerification -import Wire.API.Routes.Public.Brig.OAuth -import Wire.API.Routes.Public.Cannon -import Wire.API.Routes.Public.Cargohold -import Wire.API.Routes.Public.Galley -import Wire.API.Routes.Public.Gundeck -import Wire.API.Routes.Public.Proxy -import Wire.API.Routes.Public.Spar +import Wire.API.Routes.Public.Swagger import Wire.API.Routes.Public.Util import Wire.API.Routes.Version import Wire.API.SwaggerHelper (cleanupSwagger) import Wire.API.SystemSettings import Wire.API.Team qualified as Public +import Wire.API.Team.Feature qualified as Feature import Wire.API.Team.LegalHold (LegalholdProtectee (..)) import Wire.API.Team.Member (HiddenPerm (..), IsPerm (..), hasPermission) import Wire.API.User (RegisterError (RegisterErrorAllowlistError), UserProfile) @@ -160,6 +154,7 @@ import Wire.AuthenticationSubsystem as AuthenticationSubsystem import Wire.AuthenticationSubsystem.Config (AuthenticationSubsystemConfig) import Wire.BackendNotificationQueueAccess import Wire.BlockListStore (BlockListStore) +import Wire.BudgetStore import Wire.ClientStore (ClientStore) import Wire.ClientStore qualified as ClientStore import Wire.ClientSubsystem (ClientSubsystem) @@ -180,10 +175,13 @@ import Wire.GalleyAPIAccess qualified as GalleyAPIAccess import Wire.HashPassword (HashPassword) import Wire.IndexedUserStore (IndexedUserStore) import Wire.InvitationStore +import Wire.JwtTools (JwtTools) +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) import Wire.NotificationSubsystem import Wire.PasswordResetCodeStore (PasswordResetCodeStore) import Wire.PropertySubsystem import Wire.RateLimit +import Wire.SFT import Wire.Sem.Concurrency import Wire.Sem.Jwk (Jwk) import Wire.Sem.Metrics (Metrics) @@ -192,13 +190,13 @@ import Wire.Sem.Paging.Cassandra import Wire.Sem.Random (Random) import Wire.SessionStore (SessionStore) import Wire.SparAPIAccess -import Wire.TeamCollaboratorsSubsystem import Wire.TeamInvitationSubsystem import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem import Wire.UserGroupSubsystem (UserGroupSubsystem) import Wire.UserGroupSubsystem qualified as UserGroup import Wire.UserKeyStore +import Wire.UserPendingActivationStore (UserPendingActivationStore) import Wire.UserSearch.Types import Wire.UserStore (UserStore) import Wire.UserStore qualified as UserStore @@ -238,22 +236,11 @@ internalEndpointsSwaggerDocsAPIs = -- -- Dual to `internalEndpointsSwaggerDocsAPI`. versionedSwaggerDocsAPI :: Servant.Server VersionedSwaggerDocsAPI -versionedSwaggerDocsAPI (Just (VersionNumber V17)) = +versionedSwaggerDocsAPI (Just (VersionNumber V18)) = swaggerSchemaUIServer $ - ( serviceSwagger @VersionAPITag @'V17 - <> serviceSwagger @BrigAPITag @'V17 - <> serviceSwagger @GalleyAPITag @'V17 - <> serviceSwagger @SparAPITag @'V17 - <> serviceSwagger @CargoholdAPITag @'V17 - <> serviceSwagger @CannonAPITag @'V17 - <> serviceSwagger @GundeckAPITag @'V17 - <> serviceSwagger @ProxyAPITag @'V17 - <> serviceSwagger @OAuthAPITag @'V17 - ) - & S.info . S.title .~ "Wire-Server API" + devVersionSwagger & S.info . S.description ?~ $((unTypeCode . embedText) =<< makeRelativeToProject "docs/swagger.md") - & S.servers .~ [S.Server ("/" <> toUrlPiece V17) Nothing mempty] - & cleanupSwagger +versionedSwaggerDocsAPI (Just (VersionNumber V17)) = swaggerPregenUIServer $(pregenSwagger V17) versionedSwaggerDocsAPI (Just (VersionNumber V16)) = swaggerPregenUIServer $(pregenSwagger V16) versionedSwaggerDocsAPI (Just (VersionNumber V15)) = swaggerPregenUIServer $(pregenSwagger V15) versionedSwaggerDocsAPI (Just (VersionNumber V14)) = swaggerPregenUIServer $(pregenSwagger V14) @@ -390,7 +377,6 @@ servantSitemap :: Member Now r, Member PasswordResetCodeStore r, Member PropertySubsystem r, - Member PublicKeyBundle r, Member SFT r, Member TinyLog r, Member UserKeyStore r, @@ -416,14 +402,15 @@ servantSitemap :: Member CryptoSign r, Member Random r, Member UserGroupSubsystem r, - Member TeamCollaboratorsSubsystem r, Member TeamSubsystem r, Member AppSubsystem r, Member ClientStore r, + Member MlsKeyPackageSubsystem r, Member ClientSubsystem r, Member (Error FederationError) r, Member BackendNotificationQueueAccess r, - HasBrigFederationAccess m r + HasBrigFederationAccess m r, + Member BudgetStore r ) => ServerT BrigAPI (Handler r) servantSitemap = @@ -874,7 +861,6 @@ createClientDPoPAccessToken :: forall api endpoint r. ( Member JwtTools r, Member Now r, - Member PublicKeyBundle r, IsElem endpoint api, HasLink endpoint, MkLink endpoint Link ~ (ClientId -> Link), @@ -1114,11 +1100,12 @@ listUsersByIdsOrHandlesV3 self q = do -- using a new return type listUsersByIdsOrHandles :: forall r. - (Member UserSubsystem r, Member UserStore r) => + (Member UserSubsystem r, Member UserStore r, Member ClientStore r, Member GalleyAPIAccess r) => UserId -> + Maybe Bool -> Public.ListUsersQuery -> Handler r ListUsersById -listUsersByIdsOrHandles self q = do +listUsersByIdsOrHandles self includeContactStatus q = do lself <- qualifyLocal self (errors, foundUsers) <- case q of Public.ListUsersByIds us -> @@ -1128,7 +1115,11 @@ listUsersByIdsOrHandles self q = do (l, r) <- byIds lself us r' <- Handle.filterHandleResults lself r pure (l, r') - pure $ ListUsersById foundUsers $ fst <$$> nonEmpty errors + foundUsers' <- + if includeContactStatus == Just True + then enrichContactStatus lself foundUsers + else pure foundUsers + pure $ ListUsersById foundUsers' $ fst <$$> nonEmpty errors where byIds :: Local UserId -> @@ -1136,6 +1127,49 @@ listUsersByIdsOrHandles self q = do Handler r ([(Qualified UserId, FederationError)], [Public.UserProfile]) byIds lself uids = lift (liftSem (getUserProfilesWithErrors lself uids)) +enrichContactStatus :: + forall r. + (Member ClientStore r, Member UserSubsystem r, Member GalleyAPIAccess r) => + Local UserId -> + [Public.UserProfile] -> + Handler r [Public.UserProfile] +enrichContactStatus lself profiles = do + let localProfiles = filter ((== tDomain lself) . qDomain . Public.profileQualifiedId) profiles + if null localProfiles + then pure profiles + else do + serverMLS <- isMLSEnabled + (mlsAvailable, allowedCipherSuites) <- + if not serverMLS + then pure (False, Set.empty) + else do + requesterFeatures <- lift . liftSem $ GalleyAPIAccess.getAllTeamFeaturesForUser (Just (tUnqualified lself)) + let mlsConfig = Feature.npProject @Feature.MLSConfig requesterFeatures + pure + ( serverMLS + && mlsConfig.status == Feature.FeatureStatusEnabled + && ProtocolMLSTag `elem` mlsConfig.config.mlsSupportedProtocols, + Set.fromList mlsConfig.config.mlsAllowedCipherSuites + ) + let localUserIds = qUnqualified . Public.profileQualifiedId <$> localProfiles + clients <- lift . liftSem $ ClientStore.lookupClientsBulk localUserIds + let users = + Map.fromList + [ (uid, (profile.profileSupportedProtocols, fromMaybe Set.empty (Map.lookup uid (Public.userMap clients)))) + | profile <- localProfiles, + let uid = qUnqualified profile.profileQualifiedId + ] + contactability <- lift . liftSem $ User.isUsersContactable users mlsAvailable allowedCipherSuites + for profiles $ enrichProfile contactability + where + enrichProfile contactability profile + -- the federated case is not checked, yet + | qDomain profile.profileQualifiedId /= tDomain lself = pure profile + | otherwise = do + let uid = qUnqualified profile.profileQualifiedId + contactable = Map.findWithDefault False uid contactability + pure profile {Public.profileContactStatus = Just (Public.ContactStatus (if contactable then Public.Contactable else Public.NonContactable))} + newtype GetActivationCodeResp = GetActivationCodeResp (Public.ActivationKey, Public.ActivationCode) @@ -1456,6 +1490,7 @@ deleteSelfUser :: Member (Embed HttpClientIO) r, Member UserKeyStore r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member EmailSubsystem r, Member UserSubsystem r, @@ -1477,6 +1512,7 @@ deleteSelfUser lu body = do verifyDeleteUser :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member TinyLog r, Member UserKeyStore r, @@ -1809,12 +1845,7 @@ getApp lusr tid uid = lift . liftSem $ do getApps :: (_) => Local UserId -> TeamId -> Handler r [UserProfile] getApps lusr tid = lift . liftSem $ do - -- Check if requesting user is a member of the team - requestingUserTeam <- getUserTeam (tUnqualified lusr) - unless (requestingUserTeam == Just tid) $ - throw UserSubsystemProfileNotFound - - getLocalAppProfiles (qualifyAs lusr tid) + getLocalAppProfiles lusr tid putApp :: (_) => Local UserId -> TeamId -> UserId -> Public.PutApp -> Handler r () putApp lusr tid uid put = lift . liftSem $ AppSubsystem.updateApp lusr tid uid put diff --git a/services/brig/src/Brig/API/Types.hs b/services/brig/src/Brig/API/Types.hs index e12d0f646d2..f407dace853 100644 --- a/services/brig/src/Brig/API/Types.hs +++ b/services/brig/src/Brig/API/Types.hs @@ -33,7 +33,6 @@ where import Brig.Data.Activation (ActivationError (..)) import Data.Code import Data.Id -import Data.Jwt.Tools (DPoPTokenGenerationError (..)) import Data.Qualified import Data.RetryAfter import Imports @@ -161,16 +160,6 @@ data AccountStatusError data VerificationCodeThrottledError = VerificationCodeThrottled RetryAfter -data CertEnrollmentError - = NonceNotFound - | RustError DPoPTokenGenerationError - | KeyBundleError - | MisconfiguredRequestUrl - | ClientIdSyntaxError - | NotATeamUser - | MissingHandle - | MissingName - ------------------------------------------------------------------------------- -- Exceptions diff --git a/services/brig/src/Brig/API/User.hs b/services/brig/src/Brig/API/User.hs index 26e69fd6f23..10c4a949c2b 100644 --- a/services/brig/src/Brig/API/User.hs +++ b/services/brig/src/Brig/API/User.hs @@ -72,8 +72,6 @@ import Brig.Data.Connection (countConnections) import Brig.Data.Connection qualified as Data import Brig.Data.User import Brig.Effects.ConnectionStore -import Brig.Effects.UserPendingActivationStore (UserPendingActivation (..), UserPendingActivationStore) -import Brig.Effects.UserPendingActivationStore qualified as UserPendingActivationStore import Brig.IO.Intra qualified as Intra import Brig.Options hiding (internalEvents) import Brig.User.Auth.Cookie qualified as Auth @@ -149,6 +147,8 @@ import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem import Wire.UserGroupSubsystem import Wire.UserKeyStore +import Wire.UserPendingActivationStore (UserPendingActivation (..), UserPendingActivationStore) +import Wire.UserPendingActivationStore qualified as UserPendingActivationStore import Wire.UserStore (UserStore) import Wire.UserStore qualified as UserStore import Wire.UserSubsystem as User @@ -239,9 +239,9 @@ createUserSpar new = do tid = newUserSparTeamId new -- Create account - account <- lift $ newStoredUser new' Nothing (Just tid) handle' + account <- lift $ newStoredUser new' Nothing (Just tid) domain <- viewFederationDomain - let u = newStoredUserToUser (Qualified account domain) + let u = newStoredUserToUser (Qualified account domain) handle' lift . liftSem $ do let uid = account.id @@ -488,9 +488,9 @@ createUserWith normalizeScimDisplayName rateLimitKey new = do traverse (liftSem . HashPassword.hashPassword8 rateLimitKey) new'.newUserPassword - newStoredUser new' {newUserPassword = mHashedPassword} mbInv tid mbHandle + newStoredUser new' {newUserPassword = mHashedPassword} mbInv tid domain <- viewFederationDomain - let u = newStoredUserToUser (Qualified account domain) + let u = newStoredUserToUser (Qualified account domain) mbHandle let uid = account.id lift . liftSem $ do Log.debug $ field "user" (toByteString uid) . field "action" (val "User.createUser") @@ -637,6 +637,7 @@ createUserInviteViaScim :: Member UserKeyStore r, Member UserStore r, Member UserSubsystem r, + Member InvitationStore r, Member (UserPendingActivationStore p) r, Member TinyLog r, Member (Input (Local ())) r @@ -657,8 +658,10 @@ createUserInviteViaScim (NewUserScimInvitation tid uid extId loc name email _) = pure $ addUTCTime (realToFrac ttl) now lift . liftSem $ UserPendingActivationStore.add (UserPendingActivation uid expiresAt) - lift . liftSem $ UserStore.createUser account Nothing - newStoredUserToUser . Qualified account <$> viewFederationDomain + lift . liftSem $ do + UserStore.createUser account Nothing + InvitationStore.insertPendingScimUser tid email uid + flip newStoredUserToUser Nothing . Qualified account <$> viewFederationDomain -- | docs/reference/user/registration.md {#RefRestrictRegistration}. checkRestrictedUserCreation :: NewUser password -> ExceptT RegisterError (AppT r) () @@ -1012,6 +1015,7 @@ deleteSelfUser :: Member (Embed HttpClientIO) r, Member UserKeyStore r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserStore r, Member EmailSubsystem r, Member VerificationCodeSubsystem r, @@ -1087,6 +1091,7 @@ deleteSelfUser luid@(tUnqualified -> uid) pwd = do verifyDeleteUser :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, + Member InvitationStore r, Member UserKeyStore r, Member TinyLog r, Member UserStore r, @@ -1119,6 +1124,7 @@ ensureAccountDeleted :: ( Member (Embed HttpClientIO) r, Member NotificationSubsystem r, Member TinyLog r, + Member InvitationStore r, Member UserKeyStore r, Member UserStore r, Member Events r, @@ -1172,6 +1178,7 @@ deleteAccount :: Member UserKeyStore r, Member TinyLog r, Member UserStore r, + Member InvitationStore r, Member PropertySubsystem r, Member UserSubsystem r, Member Events r, @@ -1190,6 +1197,9 @@ deleteAccount user = do PropertySubsystem.onUserDeleted uid UserStore.deleteUser user + for_ (userEmail user) $ \email -> + for_ (userTeam user) $ \tid -> + InvitationStore.deletePendingScimUser tid email uid traverse_ (removeUserFromAllGroups uid) user.userTeam diff --git a/services/brig/src/Brig/AWS.hs b/services/brig/src/Brig/AWS.hs index 5bcbf90ecd5..f75e983bbc2 100644 --- a/services/brig/src/Brig/AWS.hs +++ b/services/brig/src/Brig/AWS.hs @@ -69,6 +69,7 @@ import UnliftIO.Async import UnliftIO.Exception import Util.Options import Wire.AWS (canRetry, sendCatch) +import Wire.EmailSending.Options qualified as EmailOpt data Env = Env { _logger :: !Logger, @@ -99,11 +100,11 @@ newtype Amazon a = Amazon instance MonadLogger Amazon where log l m = view logger >>= \g -> Logger.log g l m -mkEnv :: Logger -> Opt.AWSOpts -> Maybe Opt.EmailAWSOpts -> Manager -> IO Env +mkEnv :: Logger -> Opt.AWSOpts -> Maybe EmailOpt.EmailAWSOpts -> Manager -> IO Env mkEnv lgr opts emailOpts mgr = do let g = Logger.clone (Just "aws.brig") lgr let pk = Opt.prekeyTable opts - let sesEndpoint = mkEndpoint SES.defaultService . Opt.sesEndpoint <$> emailOpts + let sesEndpoint = mkEndpoint SES.defaultService . EmailOpt.sesEndpoint <$> emailOpts let dynamoEndpoint = mkEndpoint DDB.defaultService <$> Opt.dynamoDBEndpoint opts e <- mkAwsEnv @@ -111,7 +112,7 @@ mkEnv lgr opts emailOpts mgr = do sesEndpoint dynamoEndpoint (mkEndpoint SQS.defaultService (Opt.sqsEndpoint opts)) - sq <- maybe (pure Nothing) (fmap Just . getQueueUrl e . Opt.sesQueue) emailOpts + sq <- maybe (pure Nothing) (fmap Just . getQueueUrl e . EmailOpt.sesQueue) emailOpts jq <- maybe (pure Nothing) (fmap Just . getQueueUrl e) (Opt.userJournalQueue opts) pure (Env g sq jq pk e) where diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index 1e50c5179ed..6c2145ea8cd 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -58,6 +58,7 @@ module Brig.App http2ManagerLens, extGetManagerLens, settingsLens, + publicKeyBundleLens, fsWatcherLens, turnEnvLens, sftEnvLens, @@ -122,13 +123,17 @@ import Cassandra qualified as Cas import Cassandra.Util (initCassandraForService) import Control.AutoUpdate import Control.Error +import Control.Exception qualified as Ex import Control.Lens hiding (index, (.=)) import Control.Monad.Catch import Control.Monad.Trans.Resource +import Data.ByteString qualified as BS +import Data.ByteString.Conversion (fromByteString) import Data.Credentials (Credentials (..)) import Data.Domain import Data.Id import Data.Misc +import Data.PEMKeys (PEMKeys) import Data.Qualified import Data.Text qualified as Text import Data.Text.Encoding (encodeUtf8) @@ -137,8 +142,8 @@ import Data.Text.IO qualified as Text import Data.Time.Clock import Database.Bloodhound qualified as ES import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) -import Hasql.Pool qualified as HasqlPool -import Hasql.Pool.Extended +import Hasql.Pool.Extended (initPostgresPool) +import Hasql.Pool.Extended qualified as HasqlPool import Imports import Network.AMQP qualified as Q import Network.AMQP.Extended qualified as Q @@ -164,6 +169,7 @@ import Wire.API.Routes.Version import Wire.API.User.Identity import Wire.AuthenticationSubsystem.Config (ZAuthEnv) import Wire.AuthenticationSubsystem.Config qualified as AuthenticationSubsystem +import Wire.EmailSending.Options qualified as EmailOpt import Wire.EmailSending.SMTP qualified as SMTP import Wire.EmailSubsystem.Template (Localised, TemplateBranding, forLocale) import Wire.EmailSubsystem.Templates.User @@ -205,6 +211,7 @@ data Env = Env http2Manager :: Http2Manager, extGetManager :: (Manager, [Fingerprint Rsa] -> SSL.SSL -> IO ()), settings :: Settings, + publicKeyBundle :: Maybe PEMKeys, fsWatcher :: FS.WatchManager, turnEnv :: Calling.TurnEnv, sftEnv :: Maybe Calling.SFTEnv, @@ -225,6 +232,24 @@ data Env = Env makeLensesWith (lensRules & lensField .~ suffixNamer) ''Env +-- | Read and parse the DPoP public key bundle once at startup. Returns +-- 'Nothing' when no path is configured or the file is missing/unparseable; in +-- the latter case a warning is logged to aid diagnosis. +loadPublicKeyBundle :: Logger -> Maybe FilePath -> IO (Maybe PEMKeys) +loadPublicKeyBundle lgr path = case path of + Nothing -> pure Nothing + Just fp -> do + contents :: Either Ex.IOException ByteString <- Ex.try $ BS.readFile fp + case contents of + Left _ -> do + Log.warn lgr (Log.msg ("Failed to read DPoP public key bundle from " <> fp)) + pure Nothing + Right bs -> case fromByteString bs of + Nothing -> do + Log.warn lgr (Log.msg ("Failed to parse DPoP public key bundle from " <> fp)) + pure Nothing + Just keys -> pure $ Just keys + newEnv :: Opts -> IO Env newEnv opts = do Just md5 <- getDigestByName "MD5" @@ -276,6 +301,7 @@ newEnv opts = do rateLimitEnv <- newRateLimitEnv opts.settings.passwordHashingRateLimit hasqlPool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword amqpJobsPublisherChannel <- Q.mkRabbitMqChannelMVar lgr (Just "brig") opts.rabbitmq + pubKeyBundle <- loadPublicKeyBundle lgr opts.settings.publicKeyBundle pure $! Env { cargohold = mkEndpoint $ opts.cargohold, @@ -303,6 +329,7 @@ newEnv opts = do http2Manager = h2Mgr, extGetManager = ext, settings = opts.settings, + publicKeyBundle = pubKeyBundle, turnEnv = turn, sftEnv = mSFTEnv, fsWatcher = w, @@ -321,15 +348,15 @@ newEnv opts = do postgresMigration = opts.postgresMigration } where - emailConn _ (Opt.EmailAWS aws) = pure (Just aws, Nothing) - emailConn lgr (Opt.EmailSMTP s) = do + emailConn _ (EmailOpt.EmailAWS aws) = pure (Just aws, Nothing) + emailConn lgr (EmailOpt.EmailSMTP s) = do let h = s.smtpEndpoint.host p = Just . fromInteger . toInteger $ s.smtpEndpoint.port - smtpCredentials <- case Opt.smtpCredentials s of - Just (Opt.EmailSMTPCredentials u p') -> do + smtpCredentials <- case EmailOpt.smtpCredentials s of + Just (EmailOpt.EmailSMTPCredentials u p') -> do Just . (SMTP.Username u,) . SMTP.Password <$> initCredentials p' _ -> pure Nothing - smtp <- SMTP.initSMTP lgr h p smtpCredentials (Opt.smtpConnType s) + smtp <- SMTP.initSMTP lgr h p smtpCredentials (EmailOpt.smtpConnType s) pure (Nothing, Just smtp) mkEndpoint service = RPC.host (encodeUtf8 service.host) . RPC.port service.port $ RPC.empty diff --git a/services/brig/src/Brig/Calling/API.hs b/services/brig/src/Brig/Calling/API.hs index 78588a606d3..a84f14067c3 100644 --- a/services/brig/src/Brig/Calling/API.hs +++ b/services/brig/src/Brig/Calling/API.hs @@ -36,7 +36,6 @@ import Brig.App import Brig.Calling import Brig.Calling qualified as Calling import Brig.Calling.Internal -import Brig.Effects.SFT import Brig.Options (ListAllSFTServers (..)) import Brig.Options qualified as Opt import Control.Error (hush, throwE) @@ -64,6 +63,7 @@ import Wire.API.Team.Feature import Wire.Error import Wire.GalleyAPIAccess (GalleyAPIAccess, getAllTeamFeaturesForUser) import Wire.Network.DNS.SRV (srvTarget) +import Wire.SFT -- | ('UserId', 'ConnId' are required as args here to make sure this is an authenticated end-point.) getCallsConfigV2 :: diff --git a/services/brig/src/Brig/CanonicalInterpreter.hs b/services/brig/src/Brig/CanonicalInterpreter.hs index afa2ecc20e3..f866fc5a9ca 100644 --- a/services/brig/src/Brig/CanonicalInterpreter.hs +++ b/services/brig/src/Brig/CanonicalInterpreter.hs @@ -22,11 +22,6 @@ import Brig.App as App import Brig.DeleteQueue.Interpreter as DQ import Brig.Effects.ConnectionStore (ConnectionStore) import Brig.Effects.ConnectionStore.Cassandra (connectionStoreToCassandra) -import Brig.Effects.JwtTools -import Brig.Effects.PublicKeyBundle -import Brig.Effects.SFT (SFT, interpretSFT) -import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) -import Brig.Effects.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra) import Brig.IO.Intra (runEvents) import Brig.Options (Settings (consumableNotifications), federationDomainConfigs, federationStrategy) import Brig.Options qualified as Opt @@ -40,7 +35,7 @@ import Data.Coerce (coerce) import Data.Qualified (Local, toLocalUnsafe) import Data.ZAuth.CryptoSign (CryptoSign, runCryptoSign) import Hasql.Pool (UsageError) -import Hasql.Pool qualified as Hasql +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports import Network.Wai.Utilities.Error qualified as Wai import Polysemy @@ -69,10 +64,12 @@ import Wire.AuthenticationSubsystem.Interpreter import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess) import Wire.BackendNotificationQueueAccess.RabbitMq (interpretBackendNotificationQueueAccess) import Wire.BackendNotificationQueueAccess.RabbitMq qualified as BackendNotificationQueueAccess -import Wire.BackgroundJobsPublisher (BackgroundJobsPublisher) -import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobsPublisherRabbitMQ) +import Wire.BackgroundJobsPublisher (BackgroundJobPublisher) +import Wire.BackgroundJobsPublisher.RabbitMQ (interpretBackgroundJobPublisherRabbitMQ) import Wire.BlockListStore import Wire.BlockListStore.Cassandra +import Wire.BudgetStore +import Wire.BudgetStore.Cassandra import Wire.ClientStore (ClientStore) import Wire.ClientStore.Cassandra import Wire.ClientStore.DynamoDB (OptimisticLockEnv (..)) @@ -112,7 +109,12 @@ import Wire.IndexedUserStore import Wire.IndexedUserStore.ElasticSearch import Wire.InvitationStore (InvitationStore) import Wire.InvitationStore.Cassandra (interpretInvitationStoreToCassandra) +import Wire.JwtTools import Wire.MigrationLock +import Wire.MlsKeyPackageStore (MlsKeyPackageStore) +import Wire.MlsKeyPackageStore.Cassandra (interpretMlsKeyPackageStoreToCassandra) +import Wire.MlsKeyPackageSubsystem (MlsKeyPackageSubsystem) +import Wire.MlsKeyPackageSubsystem.Interpreter (interpretMlsKeyPackageSubsystem) import Wire.NotificationSubsystem import Wire.NotificationSubsystem.Interpreter (defaultNotificationSubsystemConfig, runNotificationSubsystemGundeck) import Wire.ParseException @@ -130,6 +132,7 @@ import Wire.RateLimit.Interpreter import Wire.Rpc import Wire.SAMLEmailSubsystem import Wire.SAMLEmailSubsystem.Interpreter +import Wire.SFT (SFT, interpretSFT) import Wire.Sem.Concurrency import Wire.Sem.Concurrency.IO import Wire.Sem.Delay @@ -161,6 +164,8 @@ import Wire.UserGroupSubsystem import Wire.UserGroupSubsystem.Interpreter import Wire.UserKeyStore import Wire.UserKeyStore.Cassandra +import Wire.UserPendingActivationStore (UserPendingActivationStore) +import Wire.UserPendingActivationStore.Cassandra (userPendingActivationStoreToCassandra) import Wire.UserStore import Wire.UserStore.Cassandra import Wire.UserStore.Postgres (interpretUserStorePostgres) @@ -205,9 +210,11 @@ type BrigLowerLevelEffects = Wire.Events.Events, NotificationSubsystem, BackendNotificationQueueAccess, - BackgroundJobsPublisher, + BackgroundJobPublisher, RateLimit, UserKeyStore, + MlsKeyPackageSubsystem, + MlsKeyPackageStore, UserStore, UserGroupStore, DomainRegistrationStore, @@ -242,7 +249,7 @@ type BrigLowerLevelEffects = SFT, ConnectionStore InternalPaging, Input Cas.ClientState, - Input Hasql.Pool, + Input HasqlPoolExt.Pool, Input AppSubsystemConfig, Input UserSubsystemConfig, Input VerificationCodeThrottleTTL, @@ -253,9 +260,9 @@ type BrigLowerLevelEffects = GundeckAPIAccess, FederationConfigStore, Jwk, - PublicKeyBundle, JwtTools, BlockListStore, + BudgetStore, UserPendingActivationStore InternalPaging, Now, Delay, @@ -439,9 +446,9 @@ runBrigToIO e (AppT ma) = do . runDelay . nowToIOAction e.currentTime . userPendingActivationStoreToCassandra + . budgetStoreToCassandra @Cas.Client . interpretBlockListStoreToCassandra e.casClient . interpretJwtTools - . interpretPublicKeyBundle . interpretJwk . interpretFederationDomainConfig e.casClient e.settings.federationStrategy (foldMap (remotesMapFromCfgFile . fmap (.federationDomainConfig)) e.settings.federationDomainConfigs) . runGundeckAPIAccess e.gundeckEndpoint @@ -487,9 +494,11 @@ runBrigToIO e (AppT ma) = do . domainRegistrationStore . interpretUserGroupStoreToPostgres . userStoreInterpreter + . interpretMlsKeyPackageStoreToCassandra e.casClient + . interpretMlsKeyPackageSubsystem e.settings.keyPackageMaximumLifetime e.keyPackageLocalLock . interpretUserKeyStoreCassandra e.casClient . interpretRateLimit e.rateLimitEnv - . interpretBackgroundJobsPublisherRabbitMQ e.requestId e.amqpJobsPublisherChannel + . interpretBackgroundJobPublisherRabbitMQ e.requestId e.amqpJobsPublisherChannel . interpretBackendNotificationQueueAccess (Just backendNotificationQueueEnv) . runNotificationSubsystemGundeck (defaultNotificationSubsystemConfig e.requestId) . runEvents diff --git a/services/brig/src/Brig/Data/MLS/KeyPackage.hs b/services/brig/src/Brig/Data/MLS/KeyPackage.hs deleted file mode 100644 index 20f95a40afa..00000000000 --- a/services/brig/src/Brig/Data/MLS/KeyPackage.hs +++ /dev/null @@ -1,169 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Brig.Data.MLS.KeyPackage - ( insertKeyPackages, - claimKeyPackage, - countKeyPackages, - deleteKeyPackages, - deleteAllKeyPackages, - ) -where - -import Brig.API.MLS.KeyPackages.Validation -import Brig.App -import Brig.Options -import Cassandra -import Control.Arrow -import Control.Error -import Control.Monad.Random (randomRIO) -import Data.Functor -import Data.Id -import Data.Qualified -import Data.Time.Clock -import Data.Time.Clock.POSIX -import Imports -import UnliftIO.Async -import Wire.API.MLS.CipherSuite -import Wire.API.MLS.KeyPackage -import Wire.API.MLS.LeafNode -import Wire.API.MLS.Serialisation - -insertKeyPackages :: - (MonadClient m) => - UserId -> - ClientId -> - [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> - m () -insertKeyPackages uid cid kps = retry x5 . batch $ do - setType BatchLogged - setConsistency LocalQuorum - for_ kps $ \(ref, suite, kp) -> do - addPrepQuery q (uid, cid, suite, kp, ref) - where - q :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageData, KeyPackageRef) () - q = "INSERT INTO mls_key_packages (user, client, cipher_suite, data, ref) VALUES (?, ?, ?, ?, ?)" - -claimKeyPackage :: - ( MonadReader Env m, - MonadUnliftIO m, - MonadClient m - ) => - Local UserId -> - ClientId -> - CipherSuiteTag -> - MaybeT m (KeyPackageRef, KeyPackageData) -claimKeyPackage u c suite = do - -- FUTUREWORK: investigate better locking strategies - lock <- lift $ asks (.keyPackageLocalLock) - -- get a random key package and delete it - (ref, kpd) <- MaybeT . withMVar lock . const $ do - kps <- getNonClaimedKeyPackages u c suite - mk <- liftIO (pick kps) - for mk $ \(ref, kpd) -> do - retry x5 $ write delete1Query (params LocalQuorum (tUnqualified u, c, suite, ref)) - pure (ref, kpd) - pure (ref, kpd) - where - delete1Query :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageRef) () - delete1Query = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref = ?" - --- | Fetch all unclaimed non-expired key packages for a given client and delete --- from the database those that have expired. -getNonClaimedKeyPackages :: - ( MonadReader Env m, - MonadClient m - ) => - Local UserId -> - ClientId -> - CipherSuiteTag -> - m [(KeyPackageRef, KeyPackageData)] -getNonClaimedKeyPackages u c suite = do - kps <- retry x1 $ query lookupQuery (params LocalQuorum (tUnqualified u, c, suite)) - let decodedKps = foldMap (keepDecoded . (decodeKp &&& id)) kps - - now <- liftIO getPOSIXTime - mMaxLifetime <- asks (.settings.keyPackageMaximumLifetime) - - let (kpsExpired, kpsNonExpired) = - partition (hasExpired now mMaxLifetime) decodedKps - -- delete expired key packages - deleteKeyPackages (tUnqualified u) c suite (map (\(_, (ref, _)) -> ref) kpsExpired) - pure $ fmap snd kpsNonExpired - where - lookupQuery :: PrepQuery R (UserId, ClientId, CipherSuiteTag) (KeyPackageRef, KeyPackageData) - lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" - - decodeKp :: (a, KeyPackageData) -> Maybe KeyPackage - decodeKp = hush . decodeMLS' . kpData . snd - - keepDecoded :: (Maybe a, b) -> [(a, b)] - keepDecoded (Nothing, _) = [] - keepDecoded (Just v, w) = [(v, w)] - - hasExpired :: POSIXTime -> Maybe NominalDiffTime -> (KeyPackage, a) -> Bool - hasExpired now mMaxLifetime (kp, _) = - case kp.leafNode.source of - LeafNodeSourceKeyPackage lt -> - either (const True) (const False) . validateLifetime' now mMaxLifetime $ lt - _ -> True -- the assumption is the key package is valid and has the - -- required extensions so we return 'True' - -countKeyPackages :: - ( MonadReader Env m, - MonadClient m - ) => - Local UserId -> - ClientId -> - CipherSuiteTag -> - m Int64 -countKeyPackages u c suite = fromIntegral . length <$> getNonClaimedKeyPackages u c suite - -deleteKeyPackages :: (MonadClient m) => UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> m () -deleteKeyPackages u c suite refs = - retry x5 $ - write - deleteQuery - (params LocalQuorum (u, c, suite, refs)) - where - deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, [KeyPackageRef]) () - deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref in ?" - -deleteAllKeyPackages :: - (MonadClient m, MonadUnliftIO m, Foldable f) => - UserId -> - ClientId -> - f CipherSuiteTag -> - m () -deleteAllKeyPackages u c suites = - pooledForConcurrentlyN_ 16 suites $ \suite -> - retry x5 $ - write - deleteQuery - (params LocalQuorum (u, c, suite)) - where - deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag) () - deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" - --------------------------------------------------------------------------------- --- Utilities - -pick :: [a] -> IO (Maybe a) -pick [] = pure Nothing -pick xs = do - i <- randomRIO (0, length xs - 1) - pure (atMay xs i) diff --git a/services/brig/src/Brig/Data/User.hs b/services/brig/src/Brig/Data/User.hs index 3a36c4c3054..4163ccd4927 100644 --- a/services/brig/src/Brig/Data/User.hs +++ b/services/brig/src/Brig/Data/User.hs @@ -22,14 +22,12 @@ module Brig.Data.User ( -- * Creation newStoredUser, newStoredUserViaScim, - invitationIdToUserId, ) where import Brig.App import Brig.Options import Control.Error -import Data.Handle (Handle) import Data.Id import Data.Json.Util (toUTCTimeMillis) import Data.Range (fromRange) @@ -41,25 +39,15 @@ import Wire.API.User import Wire.AuthenticationSubsystem.Config import Wire.StoredUser --- | Pending invitation users reuse the invitation UUID as the user UUID. -invitationIdToUserId :: InvitationId -> UserId -invitationIdToUserId = Id . toUUID - -- | Preconditions: -- -- 1. @newUserUUID u == Just inv || isNothing (newUserUUID u)@. --- 2. If @isJust@, @mbHandle@ must be claimed by user with id @inv@. --- --- Condition (2.) is essential for maintaining handle uniqueness. It is guaranteed by the --- fact that we're setting getting @mbHandle@ from table @"user"@, and when/if it was added --- there, it was claimed properly. newStoredUser :: NewUser Password -> Maybe InvitationId -> Maybe TeamId -> - Maybe Handle -> AppT r NewStoredUser -newStoredUser u inv tid mbHandle = do +newStoredUser u inv tid = do defLoc <- defaultUserLocale <$> asks (.settings) uid <- Id <$> do @@ -108,7 +96,6 @@ newStoredUser u inv tid mbHandle = do country = l.lCountry, providerId = Nothing, serviceId = Nothing, - handle = mbHandle, expires = e, teamId = tid, managedBy = managedBy, @@ -141,7 +128,6 @@ newStoredUserViaScim uid externalId tid locale name email = do country = loc.lCountry, providerId = Nothing, serviceId = Nothing, - handle = Nothing, expires = Nothing, teamId = Just tid, managedBy = ManagedByScim, diff --git a/services/brig/src/Brig/Effects/PublicKeyBundle.hs b/services/brig/src/Brig/Effects/PublicKeyBundle.hs deleted file mode 100644 index 5b8775c4d54..00000000000 --- a/services/brig/src/Brig/Effects/PublicKeyBundle.hs +++ /dev/null @@ -1,37 +0,0 @@ -{-# LANGUAGE TemplateHaskell #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2025 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Brig.Effects.PublicKeyBundle where - -import Control.Exception -import Data.ByteString qualified as BS -import Data.ByteString.Conversion -import Data.PEMKeys -import Imports -import Polysemy - -data PublicKeyBundle m a where - Get :: FilePath -> PublicKeyBundle m (Maybe PEMKeys) - -makeSem ''PublicKeyBundle - -interpretPublicKeyBundle :: (Member (Embed IO) r) => Sem (PublicKeyBundle ': r) a -> Sem r a -interpretPublicKeyBundle = interpret $ \(Get fp) -> do - contents :: Either IOException ByteString <- liftIO $ try $ BS.readFile fp - pure $ either (const Nothing) fromByteString contents diff --git a/services/brig/src/Brig/Index/Eval.hs b/services/brig/src/Brig/Index/Eval.hs index f3b8f1c83b4..ea72f9aeef5 100644 --- a/services/brig/src/Brig/Index/Eval.hs +++ b/services/brig/src/Brig/Index/Eval.hs @@ -39,9 +39,9 @@ import Data.Credentials (Credentials (..)) import Data.Id import Database.Bloodhound qualified as ES import Database.Bloodhound.Internal.Client (BHEnv (..)) -import Hasql.Pool -import Hasql.Pool qualified as Hasql +import Hasql.Pool (UsageError) import Hasql.Pool.Extended +import Hasql.Pool.Extended qualified as Hasql import Imports import Network.HTTP.Client (Manager) import Polysemy @@ -93,6 +93,11 @@ type BrigIndexEffectStack = type SemDeps = (Manager, ClientState, Hasql.Pool, BHEnv, IndexedUserStoreConfig, RequestId, IndexName) +newtype PostgresUsageException = PostgresUsageException UsageError + deriving (Show) + +instance Exception PostgresUsageException + mkSemDeps :: ESConnectionSettings -> CassandraSettings -> PostgresSettings -> Logger -> IO SemDeps mkSemDeps esConn cas pg logger = do mgr <- initHttpManagerWithTLSConfig esConn.esInsecureSkipVerifyTls esConn.esCaCert @@ -127,7 +132,7 @@ runSem (mgr, casClient, pgPool, bhEnv, indexedUserStoreConfig, reqId, migrationI runFinal . embedToFinal . throwErrorToIOFinal @ClientError - . throwErrorToIOFinal @UsageError + . throwPostgresUsageErrorToIOFinal . runInputConst pgPool . loggerToTinyLogReqId reqId logger . ignoreMetrics @@ -148,6 +153,12 @@ throwErrorToIOFinal action = do Left e -> embedFinal $ throwIO e Right a -> pure a +throwPostgresUsageErrorToIOFinal :: (Member (Final IO) r) => InterpreterFor (Error UsageError) r +throwPostgresUsageErrorToIOFinal action = do + runError action >>= \case + Left e -> embedFinal $ throwIO (PostgresUsageException e) + Right a -> pure a + runCommand :: Logger -> Command -> IO () runCommand l = \case Create es galley -> do diff --git a/services/brig/src/Brig/Index/Options.hs b/services/brig/src/Brig/Index/Options.hs index bbf0c9880ed..b19c1f3124f 100644 --- a/services/brig/src/Brig/Index/Options.hs +++ b/services/brig/src/Brig/Index/Options.hs @@ -367,13 +367,6 @@ poolConfigParser = <> help "Pool acquisition timeout in seconds" <> value (unsafeParseDuration "10s") ) - <*> option - (eitherReader (parseDuration . Text.pack)) - ( long "pg-pool-aging-timeout" - <> metavar "Duration" - <> help "Pool aging timeout in seconds" - <> value (unsafeParseDuration "1d") - ) <*> option (eitherReader (parseDuration . Text.pack)) ( long "pg-pool-idleness-timeout" diff --git a/services/brig/src/Brig/InternalEvent/Process.hs b/services/brig/src/Brig/InternalEvent/Process.hs index af018889184..9d148ac70ff 100644 --- a/services/brig/src/Brig/InternalEvent/Process.hs +++ b/services/brig/src/Brig/InternalEvent/Process.hs @@ -38,6 +38,7 @@ import Wire.API.UserEvent import Wire.AuthenticationSubsystem import Wire.ClientStore (ClientStore) import Wire.Events (Events) +import Wire.InvitationStore (InvitationStore) import Wire.NotificationSubsystem import Wire.PropertySubsystem import Wire.Sem.Concurrency @@ -59,6 +60,7 @@ onEvent :: Member (Input (Local ())) r, Member UserKeyStore r, Member UserStore r, + Member InvitationStore r, Member PropertySubsystem r, Member UserSubsystem r, Member Events r, diff --git a/services/brig/src/Brig/Options.hs b/services/brig/src/Brig/Options.hs index da75b90138a..df05f68fd7d 100644 --- a/services/brig/src/Brig/Options.hs +++ b/services/brig/src/Brig/Options.hs @@ -57,7 +57,7 @@ import Wire.API.Team.Feature import Wire.API.User import Wire.AuthenticationSubsystem.Config (ZAuthSettings) import Wire.AuthenticationSubsystem.Cookie.Limit -import Wire.EmailSending.SMTP (SMTPConnType (..)) +import Wire.EmailSending.Options (EmailOpts) import Wire.EmailSubsystem.Template (TeamOpts) import Wire.PostgresMigrationOpts import Wire.RateLimit.Interpreter @@ -109,41 +109,6 @@ data AWSOpts = AWSOpts instance FromJSON AWSOpts -data EmailAWSOpts = EmailAWSOpts - { -- | Event feedback queue for SES - -- (e.g. for email bounces and complaints) - sesQueue :: !Text, - -- | AWS SES endpoint - sesEndpoint :: !AWSEndpoint - } - deriving (Show, Generic) - -instance FromJSON EmailAWSOpts - -data EmailSMTPCredentials = EmailSMTPCredentials - { -- | Username to authenticate - -- against the SMTP server - smtpUsername :: !Text, - -- | File containing password to - -- authenticate against the SMTP server - smtpPassword :: !FilePathSecrets - } - deriving (Show, Generic) - -instance FromJSON EmailSMTPCredentials - -data EmailSMTPOpts = EmailSMTPOpts - { -- | Hostname of the SMTP server to connect to - smtpEndpoint :: !Endpoint, - smtpCredentials :: !(Maybe EmailSMTPCredentials), - -- | Which type of connection to use - -- against the SMTP server {tls,ssl,plain} - smtpConnType :: !SMTPConnType - } - deriving (Show, Generic) - -instance FromJSON EmailSMTPOpts - data StompOpts = StompOpts { host :: !Text, port :: !Int, @@ -224,16 +189,6 @@ data ProviderOpts = ProviderOpts instance FromJSON ProviderOpts -data EmailOpts - = EmailAWS EmailAWSOpts - | EmailSMTP EmailSMTPOpts - deriving (Show, Generic) - -instance FromJSON EmailOpts where - parseJSON o = - EmailAWS <$> parseJSON o - <|> EmailSMTP <$> parseJSON o - data EmailSMSOpts = EmailSMSOpts { email :: !EmailOpts, general :: !EmailSMSGeneralOpts, diff --git a/services/brig/src/Brig/Provider/API.hs b/services/brig/src/Brig/Provider/API.hs index ae666e1f2b0..7c5089e9f3c 100644 --- a/services/brig/src/Brig/Provider/API.hs +++ b/services/brig/src/Brig/Provider/API.hs @@ -710,7 +710,7 @@ updateServiceWhitelist :: TeamId -> Public.UpdateServiceWhitelist -> (Handler r) UpdateServiceWhitelistResp -updateServiceWhitelist uid con tid upd = do +updateServiceWhitelist uid _con tid upd = do -- Preconditions guardSecondFactorDisabled (Just uid) let pid = updateServiceWhitelistProvider upd @@ -735,7 +735,12 @@ updateServiceWhitelist uid con tid upd = do .| C.mapM_ ( unsafePooledMapConcurrentlyN_ 16 - (uncurry (deleteBot uid (Just con))) + ( \(bid, cid) -> + -- Each bot removes itself: the team admin de-whitelisting + -- the service is not necessarily allowed to remove members in the + -- conversations the bots are in. + deleteBot (botUserId bid) Nothing bid cid + ) ) wrapClientE $ DB.deleteServiceWhitelist (Just tid) pid sid pure UpdateServiceWhitelistRespChanged @@ -841,7 +846,6 @@ addBot zuid zcon cid add = do country = locale.lCountry, serviceId = Just sid, providerId = Just pid, - handle = Nothing, teamId = Nothing, managedBy = ManagedByWire, supportedProtocols = defSupportedProtocols, diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 5b5d0f96818..7cc07fc7f2d 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -28,10 +28,8 @@ import Brig.AWS.SesNotification qualified as SesNotification import Brig.App import Brig.Calling qualified as Calling import Brig.CanonicalInterpreter -import Brig.Effects.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore) -import Brig.Effects.UserPendingActivationStore qualified as UsersPendingActivationStore import Brig.InternalEvent.Process qualified as Internal -import Brig.Options hiding (internalEvents, sesQueue) +import Brig.Options hiding (internalEvents) import Brig.Queue qualified as Queue import Brig.Version import Control.Concurrent.Async qualified as Async @@ -45,6 +43,7 @@ import Data.Metrics.AWS (gaugeTokenRemaing) import Data.Metrics.Servant qualified as Metrics import Data.Proxy (Proxy (Proxy)) import Data.Text (unpack) +import Hasql.Pool.Extended (rawPool) import Imports hiding (head) import Network.HTTP.Media qualified as HTTPMedia import Network.HTTP.Types qualified as HTTP @@ -73,6 +72,8 @@ import Wire.DeleteQueue import Wire.OpenTelemetry (withTracer) import Wire.PostgresMigrations import Wire.Sem.Paging qualified as P +import Wire.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore) +import Wire.UserPendingActivationStore qualified as UsersPendingActivationStore import Wire.UserStore -- FUTUREWORK: If any of these async threads die, we will have no clue about it @@ -82,7 +83,7 @@ import Wire.UserStore run :: Opts -> IO () run opts = withTracer \tracer -> do (app, e) <- mkApp opts - runAllMigrations e.hasqlPool e.appLogger + runAllMigrations e.hasqlPool.rawPool e.appLogger let s = Server.newSettings (server e) internalEventListener <- Async.async $ @@ -113,7 +114,7 @@ run opts = withTracer \tracer -> do migratePostgres :: Opts -> Bool -> IO () migratePostgres opts resetFirst = do logger <- initLogger opts - pool <- initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword + pool <- (.rawPool) <$> initPostgresPool opts.postgresqlPool opts.postgresql opts.postgresqlPassword when resetFirst $ resetSchema pool logger runAllMigrations pool logger flush logger diff --git a/services/brig/src/Brig/Schema/Run.hs b/services/brig/src/Brig/Schema/Run.hs index bef0e82ce37..560cf64f2e7 100644 --- a/services/brig/src/Brig/Schema/Run.hs +++ b/services/brig/src/Brig/Schema/Run.hs @@ -67,6 +67,7 @@ import Brig.Schema.V89_UpdateDomainRegistrationSchema qualified as V89_UpdateDom import Brig.Schema.V90_DomainRegistrationTeamIndex qualified as V90_DomainRegistrationTeamIndex import Brig.Schema.V91_UpdateDomainRegistrationSchema_AddWebappUrl qualified as V91_UpdateDomainRegistrationSchema_AddWebappUrl import Brig.Schema.V92_AddUserType qualified as V92_AddUserType +import Brig.Schema.V93_AddScimPendingUserEmail qualified as V93_AddScimPendingUserEmail import Cassandra.MigrateSchema (migrateSchema) import Cassandra.Schema import Control.Exception (finally) @@ -140,7 +141,8 @@ migrations = V89_UpdateDomainRegistrationSchema.migration, V90_DomainRegistrationTeamIndex.migration, V91_UpdateDomainRegistrationSchema_AddWebappUrl.migration, - V92_AddUserType.migration + V92_AddUserType.migration, + V93_AddScimPendingUserEmail.migration -- FUTUREWORK: undo V41 (searchable flag); we stopped using it in -- https://github.com/wireapp/wire-server/pull/964 ] diff --git a/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs b/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs new file mode 100644 index 00000000000..bca1d4ea904 --- /dev/null +++ b/services/brig/src/Brig/Schema/V93_AddScimPendingUserEmail.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Brig.Schema.V93_AddScimPendingUserEmail + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = + Migration 93 "Add lookup table for pending SCIM users by team and email" $ + schema' + [r| + CREATE TABLE team_scim_pending_user_email + ( team uuid + , email text + , user uuid + , primary key ((team, email), user) + ) + |] diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index a612f9ec371..48430fbebab 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -31,8 +31,6 @@ import Brig.API.User (createUserInviteViaScim) import Brig.API.User qualified as API import Brig.API.Util (logEmail, logInvitationCode) import Brig.App as App -import Brig.Data.User (invitationIdToUserId) -import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Template import Control.Lens (view, (^.)) import Control.Monad.Trans.Except @@ -62,7 +60,6 @@ import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as Team import Wire.API.Routes.Named import Wire.API.Routes.Public.Brig (TeamsAPI) import Wire.API.Team -import Wire.API.Team.Collaborator import Wire.API.Team.Invitation import Wire.API.Team.Invitation qualified as Public (Invitation (..)) import Wire.API.Team.Member (teamMembers) @@ -81,12 +78,12 @@ import Wire.IndexedUserStore (IndexedUserStore, getTeamSize) import Wire.InvitationStore (InvitationStore (..), PaginatedResult (..), StoredInvitation (..)) import Wire.InvitationStore qualified as Store import Wire.Sem.Concurrency -import Wire.TeamCollaboratorsSubsystem import Wire.TeamInvitationSubsystem import Wire.TeamInvitationSubsystem.Interpreter (toInvitation) import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem import Wire.UserKeyStore +import Wire.UserPendingActivationStore (UserPendingActivationStore) import Wire.UserStore import Wire.UserSubsystem import Wire.UserSubsystem.Error @@ -101,7 +98,6 @@ servantAPI :: Member (Input (Local ())) r, Member (Error UserSubsystemError) r, Member IndexedUserStore r, - Member TeamCollaboratorsSubsystem r, Member TeamSubsystem r ) => ServerT TeamsAPI (Handler r) @@ -115,8 +111,6 @@ servantAPI = :<|> Named @"head-team-invitations" (lift . liftSem . headInvitationByEmail) :<|> Named @"get-team-size" (\uid tid -> lift . liftSem $ teamSizePublic uid tid) :<|> Named @"accept-team-invitation" (\luid req -> lift $ liftSem $ acceptTeamInvitation luid req.password req.code) - :<|> Named @"add-team-collaborator" (\zuid tid (NewTeamCollaborator uid perms) -> lift . liftSem $ createTeamCollaborator zuid uid tid perms) - :<|> Named @"get-team-collaborators" (\zuid tid -> lift . liftSem $ getAllTeamCollaborators zuid tid) teamSizePublic :: ( Member (Error UserSubsystemError) r, @@ -152,6 +146,7 @@ createInvitationViaScim :: ( Member BlockListStore r, Member UserKeyStore r, Member UserStore r, + Member InvitationStore r, Member (UserPendingActivationStore p) r, Member TinyLog r, Member TeamInvitationSubsystem r, diff --git a/services/brig/src/Brig/User/Auth.hs b/services/brig/src/Brig/User/Auth.hs index d00b6340196..51880fca8b1 100644 --- a/services/brig/src/Brig/User/Auth.hs +++ b/services/brig/src/Brig/User/Auth.hs @@ -36,10 +36,8 @@ where import Brig.API.Types import Brig.API.User (changeSingleAccountStatus) import Brig.App -import Brig.Budget import Brig.Options qualified as Opt import Brig.User.Auth.Cookie -import Cassandra import Control.Error hiding (bool) import Data.ByteString.Conversion (toByteString) import Data.Code qualified as Code @@ -71,6 +69,7 @@ import Wire.AuthenticationSubsystem qualified as Authentication import Wire.AuthenticationSubsystem.Config import Wire.AuthenticationSubsystem.Error (VerificationCodeError (..)) import Wire.AuthenticationSubsystem.ZAuth qualified as ZAuth +import Wire.BudgetStore import Wire.ClientStore (ClientStore) import Wire.ClientStore qualified as ClientStore import Wire.Events (Events) @@ -89,6 +88,7 @@ import Wire.UserSubsystem qualified as User login :: forall r. ( Member (Input (Local ())) r, + Member BudgetStore r, Member ActivationCodeStore r, Member Events r, Member TinyLog r, @@ -108,7 +108,7 @@ login :: login (MkLogin li pw label code) typ = do uid <- resolveLoginId li lift . liftSem . Log.debug $ field "user" (toByteString uid) . field "action" (val "User.login") - wrapClientE $ checkRetryLimit uid + checkRetryLimit uid (lift . liftSem $ Authentication.authenticateEither uid pw) >>= \case Right a -> pure a @@ -131,18 +131,16 @@ login (MkLogin li pw label code) typ = do Left VerificationCodeNoEmail -> lift (decrRetryLimit uid) >> throwE LoginFailed Right () -> pure () -decrRetryLimit :: UserId -> (AppT r) () -decrRetryLimit = wrapClient . withRetryLimit (\k b -> withBudget k b $ pure ()) +decrRetryLimit :: forall r. (Member BudgetStore r) => UserId -> (AppT r) () +decrRetryLimit = withRetryLimit (\k b -> liftSem $ withBudget k b $ pure ()) checkRetryLimit :: - ( MonadReader Env m, - MonadClient m - ) => + (Member BudgetStore r) => UserId -> - ExceptT LoginError m () + ExceptT LoginError (AppT r) () checkRetryLimit uid = flip withRetryLimit uid $ \budgetKey budget -> - checkBudget budgetKey budget >>= \case + lift (liftSem (checkBudget budgetKey budget)) >>= \case BudgetExhausted ttl -> throwE . LoginBlocked . RetryAfter . floor $ ttl BudgetedValue () remaining -> pure $ BudgetedValue () remaining diff --git a/services/brig/src/Brig/User/Client.hs b/services/brig/src/Brig/User/Client.hs index ed89f86db6d..7cc44a042cf 100644 --- a/services/brig/src/Brig/User/Client.hs +++ b/services/brig/src/Brig/User/Client.hs @@ -20,13 +20,8 @@ module Brig.User.Client ) where -import Brig.API.Types import Brig.App import Brig.Data.Nonce as Nonce -import Brig.Effects.JwtTools (JwtTools) -import Brig.Effects.JwtTools qualified as JwtTools -import Brig.Effects.PublicKeyBundle (PublicKeyBundle) -import Brig.Effects.PublicKeyBundle qualified as PublicKeyBundle import Brig.Options qualified as Opt import Control.Error import Control.Monad.Trans.Except (except) @@ -47,13 +42,15 @@ import Wire.API.MLS.Epoch (addToEpoch) import Wire.API.Routes.Internal.Brig import Wire.API.User import Wire.API.User.Client.DPoPAccessToken +import Wire.JwtTools (CertEnrollmentError (..), JwtTools) +import Wire.JwtTools qualified as JwtTools import Wire.Sem.FromUTC (FromUTC (fromUTCTime)) import Wire.Sem.Now as Now import Wire.UserSubsystem (UserSubsystem) import Wire.UserSubsystem qualified as User createClientDPoPAccessToken :: - (Member JwtTools r, Member Now r, Member PublicKeyBundle r, Member UserSubsystem r) => + (Member JwtTools r, Member Now r, Member UserSubsystem r) => Local UserId -> ClientId -> StdMethod -> @@ -93,9 +90,7 @@ createClientDPoPAccessToken luid cid method link proof = do expiresIn <- Opt.dpopTokenExpirationTimeSecs <$> asks (.settings) now <- fromUTCTime <$> lift (liftSem Now.get) let expiresAt = now & addToEpoch expiresIn - pubKeyBundle <- do - pathToKeys <- ExceptT (note KeyBundleError <$> asks (.settings.publicKeyBundle)) - ExceptT $ note KeyBundleError <$> liftSem (PublicKeyBundle.get pathToKeys) + pubKeyBundle <- ExceptT (note KeyBundleError <$> asks (.publicKeyBundle)) token <- ExceptT $ liftSem $ diff --git a/services/brig/src/Brig/User/Search/SearchIndex.hs b/services/brig/src/Brig/User/Search/SearchIndex.hs index d97167c87a5..360baa24430 100644 --- a/services/brig/src/Brig/User/Search/SearchIndex.hs +++ b/services/brig/src/Brig/User/Search/SearchIndex.hs @@ -157,7 +157,12 @@ mkUserQuery setting q = ( ES.Filter . ES.QueryBoolQuery $ boolQuery - { ES.boolQueryMustNotMatch = maybeToList $ matchSelf setting, + { ES.boolQueryMustNotMatch = + maybeToList (matchSelf setting) + <> + -- Federated search must respect the same "searchable" flag as + -- local contact search. + [ES.TermQuery (ES.Term "searchable" "false") Nothing], ES.boolQueryMustMatch = [ restrictSearchSpaceByTeam setting, restrictSearchSpaceByUserType setting.types, diff --git a/services/brig/src/Brig/User/Template.hs b/services/brig/src/Brig/User/Template.hs index ea7193faadf..9f9636f510e 100644 --- a/services/brig/src/Brig/User/Template.hs +++ b/services/brig/src/Brig/User/Template.hs @@ -18,94 +18,23 @@ module Brig.User.Template (loadUserTemplates) where import Brig.Options qualified as Opt -import Data.Text.Template import Imports -import Wire.EmailSubsystem.Template hiding (readTemplate, readText) +import Wire.EmailSubsystem.Template hiding (loadUserTemplates) +import Wire.EmailSubsystem.Template qualified as EmailTemplate import Wire.EmailSubsystem.Templates.User loadUserTemplates :: Opt.Opts -> IO (Localised UserTemplates) -loadUserTemplates o = readLocalesDir defLocale templateDir "user" $ \fp -> - UserTemplates - <$> ( VerificationEmailTemplate activationUrl - <$> readTemplate fp "email/verification-subject.txt" - <*> readTemplate fp "email/verification.txt" - <*> readTemplate fp "email/verification.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( ActivationEmailTemplate activationUrl - <$> readTemplate fp "email/activation-subject.txt" - <*> readTemplate fp "email/activation.txt" - <*> readTemplate fp "email/activation.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( ActivationEmailTemplate activationUrl - <$> readTemplate fp "email/update-subject.txt" - <*> readTemplate fp "email/update.txt" - <*> readTemplate fp "email/update.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( TeamActivationEmailTemplate teamActivationUrl - <$> readTemplate fp "email/team-activation-subject.txt" - <*> readTemplate fp "email/team-activation.txt" - <*> readTemplate fp "email/team-activation.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( PasswordResetEmailTemplate passwordResetUrl - <$> readTemplate fp "email/password-reset-subject.txt" - <*> readTemplate fp "email/password-reset.txt" - <*> readTemplate fp "email/password-reset.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( DeletionEmailTemplate deletionUserUrl - <$> readTemplate fp "email/deletion-subject.txt" - <*> readTemplate fp "email/deletion.txt" - <*> readTemplate fp "email/deletion.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( NewClientEmailTemplate - <$> readTemplate fp "email/new-client-subject.txt" - <*> readTemplate fp "email/new-client.txt" - <*> readTemplate fp "email/new-client.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( SecondFactorVerificationEmailTemplate - <$> readTemplate fp "email/verification-login-subject.txt" - <*> readTemplate fp "email/verification-login.txt" - <*> readTemplate fp "email/verification-login.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( SecondFactorVerificationEmailTemplate - <$> readTemplate fp "email/verification-scim-token-subject.txt" - <*> readTemplate fp "email/verification-scim-token.txt" - <*> readTemplate fp "email/verification-scim-token.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) - <*> ( SecondFactorVerificationEmailTemplate - <$> readTemplate fp "email/verification-delete-team-subject.txt" - <*> readTemplate fp "email/verification-delete-team.txt" - <*> readTemplate fp "email/verification-delete-team.html" - <*> pure emailSender - <*> readText fp "email/sender.txt" - ) +loadUserTemplates o = + EmailTemplate.loadUserTemplates + userTemplateOpts + o.emailSMS.general.templateDir + (Opt.defaultTemplateLocale o.settings) + o.emailSMS.general.emailSender where - gOptions = o.emailSMS.general - uOptions = o.emailSMS.user - tOptions = o.emailSMS.team - emailSender = gOptions.emailSender - activationUrl = template uOptions.activationUrl - teamActivationUrl = template tOptions.tActivationUrl - passwordResetUrl = template uOptions.passwordResetUrl - deletionUserUrl = template uOptions.deletionUrl - defLocale = Opt.defaultTemplateLocale o.settings - templateDir = gOptions.templateDir - readTemplate = readTemplateWithDefault templateDir defLocale "user" - readText = readTextWithDefault templateDir defLocale "user" + userTemplateOpts = + UserTemplateOpts + { activationUrl = o.emailSMS.user.activationUrl, + teamActivationUrl = o.emailSMS.team.tActivationUrl, + passwordResetUrl = o.emailSMS.user.passwordResetUrl, + deletionUrl = o.emailSMS.user.deletionUrl + } diff --git a/services/brig/test/integration/API/Provider.hs b/services/brig/test/integration/API/Provider.hs index 177e0a90bd8..732558e1c17 100644 --- a/services/brig/test/integration/API/Provider.hs +++ b/services/brig/test/integration/API/Provider.hs @@ -142,6 +142,10 @@ tests dom brigOpts conf p db b c g n = do testSearchWhitelistHonorUpdates conf db b, test p "de-whitelisted bots are removed" $ testWhitelistKickout dom conf db b g c, + test p "de-whitelisted bots are removed from conversations the team admin does not administer" $ + testWhitelistKickoutNotConvAdmin True dom conf db b g c, + test p "de-whitelisted bots are removed from conversations the team admin is not in" $ + testWhitelistKickoutNotConvAdmin False dom conf db b g c, test p "de-whitelisting works with deleted conversations" $ testDeWhitelistDeletedConv conf db b g c, test p "whitelist via nginz" $ testWhitelistNginz conf db b n @@ -897,8 +901,9 @@ testWhitelistKickout localDomain config db brig galley cannon = do _ <- waitFor (2 # Second) not (isMember galley lbuid cid) getBotConv galley bid cid !!! const 404 === statusCode - wsAssertMemberLeave ws qcid qowner [tUntagged lbuid] - svcAssertMemberLeave buf qowner [tUntagged lbuid] qcid + -- The bot removes itself, see 'updateServiceWhitelist' + wsAssertMemberLeave ws qcid (tUntagged lbuid) [tUntagged lbuid] + svcAssertMemberLeave buf (tUntagged lbuid) [tUntagged lbuid] qcid -- The bot should not get any further events liftIO $ timeout (2 # Second) (readChan buf) >>= \case @@ -906,6 +911,66 @@ testWhitelistKickout localDomain config db brig galley cannon = do Just (TestBotCreated _) -> assertFailure "bot got a TestBotCreated event" Just (TestBotMessage e) -> assertFailure ("bot got an event: " <> show (evtType e)) +-- | The team admin who de-whitelists a service is not necessarily allowed to +-- remove members from the conversations the service's bots are in. The bots have to +-- be kicked out either way. +testWhitelistKickoutNotConvAdmin :: + -- | Whether the team admin is a (non-admin) member of the bot's conversation + Bool -> + Domain -> + Config -> + DB.ClientState -> + Brig -> + Galley -> + Cannon -> + Http () +testWhitelistKickoutNotConvAdmin ownerInConv localDomain config db brig galley cannon = do + -- Create a team with an owner and a member + (owner, tid) <- Team.createUserWithTeam brig + member <- Team.createTeamMember brig galley owner tid fullPermissions + let memberId = userId member + qmemberId = userQualifiedId member + lowner = toLocalUnsafe localDomain owner + -- The member creates the conversation and is therefore its only conversation + -- admin; the owner joins as a plain conversation member, or not at all. + cid <- Team.createTeamConvWithRole roleNameWireMember galley tid memberId [owner | ownerInConv] Nothing + let qcid = Qualified cid localDomain + -- Create a service + withTestService config db brig defServiceApp $ \sref buf -> do + -- Add it to the conversation + let pid = sref ^. serviceRefProvider + sid = sref ^. serviceRefId + whitelistService brig owner tid pid sid + bot <- + responseJsonError + =<< (addBot brig memberId pid sid cid do + dewhitelistService brig owner tid pid sid + _ <- waitFor (2 # Second) not (isMember galley lbuid cid) + getBotConv galley bid cid + !!! const 404 === statusCode + wsAssertMemberLeave ws qcid (tUntagged lbuid) [tUntagged lbuid] + svcAssertMemberLeave buf (tUntagged lbuid) [tUntagged lbuid] qcid + -- The bot is gone from the member list the users see, too + let assertBotGone :: UserId -> Http () + assertBotGone u = do + mems <- + fmap cnvMembers . responseJsonError @_ @(OwnConversation GroupConvType) + =<< (getConversationQualified galley u qcid DB.ClientState -> Brig -> Galley -> Cannon -> Http () testDeWhitelistDeletedConv config db brig galley cannon = do -- Create a service diff --git a/services/brig/test/integration/API/User/Account.hs b/services/brig/test/integration/API/User/Account.hs index 6348fc1309c..da547287827 100644 --- a/services/brig/test/integration/API/User/Account.hs +++ b/services/brig/test/integration/API/User/Account.hs @@ -807,7 +807,8 @@ testMultipleUsers opts brig = do profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } users = [u1, u2, u3] q = ListUsersByIds $ u5 : u4 : map userQualifiedId users diff --git a/services/brig/test/integration/Run.hs b/services/brig/test/integration/Run.hs index 2fd4e49c3a8..205c48714df 100644 --- a/services/brig/test/integration/Run.hs +++ b/services/brig/test/integration/Run.hs @@ -29,7 +29,6 @@ import API.Search qualified as Search import API.Settings qualified as Settings import API.Team qualified as Team import API.TeamUserSearch qualified as TeamUserSearch -import API.Template qualified import API.User qualified as User import Bilge hiding (header, host, port) import Bilge qualified @@ -64,6 +63,7 @@ import Util.Test.SQS qualified as SQS import Web.HttpApiData import Wire.API.Federation.API import Wire.API.Routes.Version +import Wire.EmailSending.Options qualified as EmailOpt data BackendConf = BackendConf { remoteBrig :: Endpoint, @@ -145,7 +145,6 @@ runTests iConf brigOpts otherArgs = do browseTeam <- TeamUserSearch.tests brigOpts mg g b federationEnd2End <- Federation.End2end.spec brigOpts mg b g ch c f brigTwo galleyTwo ch2 cannonTwo federationEndpoints <- API.Federation.tests mg brigOpts b fedBrigClient - emailTemplates <- API.Template.tests brigOpts mg let smtp = SMTP.tests mg lg oauthAPI = API.OAuth.tests mg db b n brigOpts @@ -165,8 +164,7 @@ runTests iConf brigOpts otherArgs = do federationEndpoints, smtp, oauthAPI, - federationEnd2End, - emailTemplates + federationEnd2End ] where mkRequest (Endpoint h p) = Bilge.host (encodeUtf8 h) . Bilge.port p @@ -187,11 +185,10 @@ runTests iConf brigOpts otherArgs = do _ -> s latestVersion :: Version latestVersion = maxBound - - parseEmailAWSOpts :: IO (Maybe Opts.EmailAWSOpts) + parseEmailAWSOpts :: IO (Maybe EmailOpt.EmailAWSOpts) parseEmailAWSOpts = case Opts.email . Opts.emailSMS $ brigOpts of - (Opts.EmailAWS aws) -> pure (Just aws) - (Opts.EmailSMTP _) -> pure Nothing + (EmailOpt.EmailAWS aws) -> pure (Just aws) + (EmailOpt.EmailSMTP _) -> pure Nothing main :: IO () main = withOpenSSL $ do diff --git a/services/brig/test/unit/Test/Brig/Calling.hs b/services/brig/test/unit/Test/Brig/Calling.hs index a7ac13c3ebb..67d38fad326 100644 --- a/services/brig/test/unit/Test/Brig/Calling.hs +++ b/services/brig/test/unit/Test/Brig/Calling.hs @@ -23,7 +23,6 @@ module Test.Brig.Calling (tests) where import Brig.Calling import Brig.Calling.API import Brig.Calling.Internal -import Brig.Effects.SFT import Brig.Options import Control.Concurrent.Timeout qualified as System import Control.Lens ((^.)) @@ -52,6 +51,7 @@ import UnliftIO.Async qualified as Async import Wire.API.Call.Config import Wire.Network.DNS.Effect import Wire.Network.DNS.SRV +import Wire.SFT import Wire.Sem.Logger.TinyLog data FakeDNSEnv = FakeDNSEnv diff --git a/services/cannon/src/Cannon/App.hs b/services/cannon/src/Cannon/App.hs index a0c4ff3d88b..538e9b14acd 100644 --- a/services/cannon/src/Cannon/App.hs +++ b/services/cannon/src/Cannon/App.hs @@ -48,11 +48,22 @@ wsapp k c e pc = runWS e (go `catches` ioErrors k c) ws <- mkWebSocket conn debug $ client (key2bytes k) ~~ "websocket" .= connIdent ws registerLocal k ws - registerRemote k c `onException` (unregisterLocal k ws >> close k ws) + registerRemote k c + `onException` ( logRegisterRemoteError ws + >> unregisterLocal k ws + >> close k ws + ) timeout (maxLifetime * 1_000_000) (continue ws k) `finally` terminate k ws >>= \case Nothing -> Logger.info $ msg (val "websocket reached max lifetime") . client (key2bytes k) Just () -> pure () + logRegisterRemoteError ws = + Logger.err $ + logKey k + . client (key2bytes k) + ~~ "websocket" + .= connIdent ws + ~~ msg (val "Registering remote presence at Gundeck failed. Check Gundeck.") continue :: (MonadLogger m, MonadUnliftIO m) => Websocket -> Key -> m () continue ws k = do diff --git a/services/cargohold/cargohold.cabal b/services/cargohold/cargohold.cabal index ecc35c1f4a5..64ebd3d91d5 100644 --- a/services/cargohold/cargohold.cabal +++ b/services/cargohold/cargohold.cabal @@ -308,6 +308,7 @@ test-suite cargohold-tests other-modules: Test.CargoHold.API.AuditLogTest Test.CargoHold.API.LogJSON + Test.CargoHold.S3Test default-extensions: AllowAmbiguousTypes diff --git a/services/cargohold/src/CargoHold/API/Public.hs b/services/cargohold/src/CargoHold/API/Public.hs index a3929e7ac60..e9272d97963 100644 --- a/services/cargohold/src/CargoHold/API/Public.hs +++ b/services/cargohold/src/CargoHold/API/Public.hs @@ -30,7 +30,7 @@ import Control.Monad.Trans.Except (throwE) import Data.ByteString.Builder import qualified Data.ByteString.Builder as Builder import qualified Data.ByteString.Lazy as LBS -import Data.Domain +import Data.Domain (domainText) import Data.Id import Data.Kind import Data.Qualified @@ -45,6 +45,7 @@ import Wire.API.Asset import Wire.API.Routes.AssetBody import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Named +import Wire.API.Routes.Public (ZHostValue) import Wire.API.Routes.Public.Cargohold import Wire.API.User (AccountStatus (..), User (userStatus, userTeam)) @@ -192,7 +193,7 @@ downloadAssetV3 :: AssetKey -> Maybe AssetToken -> Maybe AssetToken -> - Maybe Text -> + Maybe ZHostValue -> Handler (Maybe (AssetLocation Absolute)) downloadAssetV3 usr key tok1 tok2 mbHostHeader = do AssetLocation <$$> V3.download (mkPrincipal usr) key (tok1 <|> tok2) mbHostHeader @@ -203,7 +204,7 @@ downloadAssetV4 :: Qualified AssetKey -> Maybe AssetToken -> Maybe AssetToken -> - Maybe Text -> + Maybe ZHostValue -> Handler (Maybe LocalOrRemoteAsset) downloadAssetV4 usr qkey tok1 tok2 mbHostHeader = let tok = tok1 <|> tok2 diff --git a/services/cargohold/src/CargoHold/API/V3.hs b/services/cargohold/src/CargoHold/API/V3.hs index b22bfd341eb..9e8997f49c9 100644 --- a/services/cargohold/src/CargoHold/API/V3.hs +++ b/services/cargohold/src/CargoHold/API/V3.hs @@ -52,6 +52,7 @@ import Data.ByteString.Conversion (toByteString') import qualified Data.CaseInsensitive as CI import Data.Conduit import qualified Data.Conduit.Attoparsec as Conduit +import Data.Domain (Domain) import Data.Id import qualified Data.List as List import Data.Qualified @@ -122,13 +123,13 @@ updateToken own key tok = do randToken :: (MonadIO m) => m V3.AssetToken randToken = liftIO $ V3.AssetToken . Ascii.encodeBase64Url <$> getRandomBytes 16 -download :: V3.Principal -> V3.AssetKey -> Maybe V3.AssetToken -> Maybe Text -> Handler (Maybe URI) +download :: V3.Principal -> V3.AssetKey -> Maybe V3.AssetToken -> Maybe Domain -> Handler (Maybe URI) download own key tok mbHost = runMaybeT $ do qown <- lift $ qualifyLocal own meta <- checkMetadata (tUntagged qown) key tok lift $ genSignedURL (Just $ tUntagged qown) (Just meta) (S3.mkKey key) mbHost -downloadUnsafe :: V3.AssetKey -> Maybe Text -> Handler URI +downloadUnsafe :: V3.AssetKey -> Maybe Domain -> Handler URI downloadUnsafe key mbHost = do meta <- S3.getMetadataV3 key genSignedURL Nothing meta (S3.mkKey key) mbHost diff --git a/services/cargohold/src/CargoHold/App.hs b/services/cargohold/src/CargoHold/App.hs index 9ab96c0b2ce..6b1097d3f93 100644 --- a/services/cargohold/src/CargoHold/App.hs +++ b/services/cargohold/src/CargoHold/App.hs @@ -57,6 +57,7 @@ import Control.Error (ExceptT, runExceptT) import Control.Exception (catch, throwIO) import Control.Lens (lensField, lensRules, makeLensesWith, non, (.~), (?~), (^.)) import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow) +import Data.Domain (Domain) import Data.Id import qualified Data.Map as Map import Data.Qualified @@ -87,7 +88,7 @@ data Env = Env requestId :: RequestId, options :: Opt.Opts, localUnit :: Local (), - multiIngress :: Map String AWS.Env + multiIngress :: Map Domain AWS.Env } makeLensesWith (lensRules & lensField .~ suffixNamer) ''Env @@ -103,7 +104,7 @@ newEnv opts = do let localDomain = toLocalUnsafe opts.settings.federationDomain () pure $ Env awsEnv logger httpMgr http2Mgr (RequestId defRequestId) opts localDomain multiIngressAWS where - initMultiIngressAWS :: Logger -> Manager -> IO (Map String AWS.Env) + initMultiIngressAWS :: Logger -> Manager -> IO (Map Domain AWS.Env) initMultiIngressAWS logger httpMgr = Map.fromList <$> mapM diff --git a/services/cargohold/src/CargoHold/Options.hs b/services/cargohold/src/CargoHold/Options.hs index aa70731bc79..a595884f159 100644 --- a/services/cargohold/src/CargoHold/Options.hs +++ b/services/cargohold/src/CargoHold/Options.hs @@ -105,7 +105,7 @@ data AWSOpts = AWSOpts -- otherwise a 404 is retuned. This option is only useful -- in the context of multi-ingress setups where one backend / deployment is -- reachable under several domains. - multiIngress :: !(Maybe (Map String AWSEndpoint)) + multiIngress :: !(Maybe (Map Domain AWSEndpoint)) } deriving (Show, Generic) diff --git a/services/cargohold/src/CargoHold/S3.hs b/services/cargohold/src/CargoHold/S3.hs index e873acf3636..58d511db2a3 100644 --- a/services/cargohold/src/CargoHold/S3.hs +++ b/services/cargohold/src/CargoHold/S3.hs @@ -22,6 +22,8 @@ module CargoHold.S3 ( S3AssetKey, S3AssetMeta (..), AssetAuditLogMetadata (..), + setAmzAuditLogMetadata, + getAmzAuditLogMetadata, uploadV3, downloadV3, getMetadataV3, @@ -62,6 +64,7 @@ import Data.ByteString.Lazy (fromStrict) import qualified Data.ByteString.Lazy as LBS import qualified Data.CaseInsensitive as CI import Data.Conduit.Binary +import Data.Domain (Domain, domainText) import qualified Data.HashMap.Lazy as HML import Data.Id import Data.Qualified (Qualified) @@ -73,6 +76,7 @@ import qualified Data.Text.Encoding as Text import Data.Time.Clock import qualified Data.UUID as UUID import Imports +import qualified Network.HTTP.Types.URI as HTTPURI import qualified System.Logger.Class as Log import System.Logger.Message (msg, val, (.=), (~~)) import Test.QuickCheck (Arbitrary (..)) @@ -273,7 +277,7 @@ updateMetadataV3 (s3Key . mkKey -> key) meta = do -- `Map` with the @Z-Host@ header's value as key. Otherwise (the default case -- that applies to most deployments), use the default AWS environment; i.e. the -- environment with @aws.s3DownloadEndpoint@. -signedURL :: (ToByteString p) => p -> Maybe Text -> Handler URI +signedURL :: (ToByteString p) => p -> Maybe Domain -> Handler URI signedURL path mbHost = do e <- awsEnvForHost now <- liftIO getCurrentTime @@ -299,7 +303,7 @@ signedURL path mbHost = do then asks (.aws) else awsEnvForHost' mbHost multiIngressConf where - awsEnvForHost' :: Maybe Text -> Map String AWS.Env -> Handler AWS.Env + awsEnvForHost' :: Maybe Domain -> Map Domain AWS.Env -> Handler AWS.Env awsEnvForHost' Nothing _ = do Log.debug $ msg (val "awsEnvForHost - multiIngress configured, but no Z-Host header provided.") @@ -307,19 +311,19 @@ signedURL path mbHost = do awsEnvForHost' (Just host) multiIngressConf = do Log.debug $ "host" - .= host + .= domainText host ~~ msg (val "awsEnvForHost - Looking up multiIngress config.") - case multiIngressConf ^. at (Text.unpack host) of + case multiIngressConf ^. at host of Nothing -> do Log.debug $ "host" - .= host + .= domainText host ~~ msg (val "awsEnvForHost - multiIngress lookup failed, no config for provided Z-Host header.") throwE noMatchingAssetEndpoint Just hostAwsEnv -> do Log.debug $ "host" - .= host + .= domainText host ~~ "s3DownloadEndpoint" .= show hostAwsEnv.amazonkaDownloadEndpoint ~~ msg (val "awsEnvForHost - multiIngress lookup succeed, using specific AWS env.") @@ -382,7 +386,8 @@ setAmzAuditLogMetadata :: AssetAuditLogMetadata -> (Text, Text) setAmzAuditLogMetadata t = (hAmzWireMetadata, encodeAuditLogMetadata t) where encodeAuditLogMetadata :: AssetAuditLogMetadata -> Text - encodeAuditLogMetadata meta = Text.decodeUtf8 (LBS.toStrict (A.encode meta)) + encodeAuditLogMetadata meta = + decodeLatin1 . HTTPURI.urlEncode False . LBS.toStrict $ A.encode meta ------------------------------------------------------------------------------- -- S3 Metadata Getters @@ -417,7 +422,13 @@ getAmzAuditLogMetadata :: [(Text, Text)] -> Maybe AssetAuditLogMetadata getAmzAuditLogMetadata = lookupCI hAmzWireMetadata >=> parseAuditLogMetadata where parseAuditLogMetadata :: Text -> Maybe AssetAuditLogMetadata - parseAuditLogMetadata t = A.decode $ fromStrict $ encodeUtf8 t + parseAuditLogMetadata t = parseJSON t <|> parseJSONBytes (HTTPURI.urlDecode False (encodeUtf8 t)) + + parseJSON :: Text -> Maybe AssetAuditLogMetadata + parseJSON = parseJSONBytes . encodeUtf8 + + parseJSONBytes :: ByteString -> Maybe AssetAuditLogMetadata + parseJSONBytes = A.decode . fromStrict ------------------------------------------------------------------------------- -- Utilities diff --git a/services/cargohold/src/CargoHold/Util.hs b/services/cargohold/src/CargoHold/Util.hs index 763866e46b8..bd4018ede6a 100644 --- a/services/cargohold/src/CargoHold/Util.hs +++ b/services/cargohold/src/CargoHold/Util.hs @@ -26,11 +26,12 @@ import CargoHold.S3 (S3AssetMeta) import qualified CargoHold.S3 as S3 import qualified CargoHold.Types as V3 import Data.ByteString.Conversion +import Data.Domain (Domain) import Data.Qualified (Qualified) import Imports import URI.ByteString hiding (urlEncode) -genSignedURL :: (ToByteString p) => Maybe (Qualified V3.Principal) -> Maybe S3AssetMeta -> p -> Maybe Text -> Handler URI +genSignedURL :: (ToByteString p) => Maybe (Qualified V3.Principal) -> Maybe S3AssetMeta -> p -> Maybe Domain -> Handler URI genSignedURL quid mMeta path mbHost = do uri <- asks (.aws.cloudFront) >>= \case diff --git a/services/cargohold/test/integration/App.hs b/services/cargohold/test/integration/App.hs index b4b9c128ed4..6a841660117 100644 --- a/services/cargohold/test/integration/App.hs +++ b/services/cargohold/test/integration/App.hs @@ -25,6 +25,8 @@ import CargoHold.Options as Opts import Control.Exception import Control.Lens import Data.ByteString.Conversion +import Data.Domain (mkDomain) +import qualified Data.Domain import qualified Data.Map as Map import qualified Data.Text as T import Imports @@ -68,10 +70,10 @@ testMultiIngressCloudFrontFails = do privateKey = "any/path" } -multiIngressMap :: Map String AWSEndpoint +multiIngressMap :: Map Data.Domain.Domain AWSEndpoint multiIngressMap = Map.singleton - "red.example.com" + (either (error . show) id $ mkDomain "red.example.com") (toAWSEndpoint "http://s3-download.red.example.com") toAWSEndpoint :: ByteString -> AWSEndpoint diff --git a/services/cargohold/test/unit/Main.hs b/services/cargohold/test/unit/Main.hs index 0a0dd4f77c8..33ddc0743ab 100644 --- a/services/cargohold/test/unit/Main.hs +++ b/services/cargohold/test/unit/Main.hs @@ -19,7 +19,8 @@ module Main (main) where import Imports import qualified Test.CargoHold.API.AuditLogTest as AuditLog +import qualified Test.CargoHold.S3Test as S3 import Test.Tasty main :: IO () -main = defaultMain (testGroup "Cargohold Unit" [AuditLog.tests]) +main = defaultMain (testGroup "Cargohold Unit" [AuditLog.tests, S3.tests]) diff --git a/services/cargohold/test/unit/Test/CargoHold/S3Test.hs b/services/cargohold/test/unit/Test/CargoHold/S3Test.hs new file mode 100644 index 00000000000..bf4d5917622 --- /dev/null +++ b/services/cargohold/test/unit/Test/CargoHold/S3Test.hs @@ -0,0 +1,61 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.CargoHold.S3Test (tests) where + +import CargoHold.S3 (AssetAuditLogMetadata (..), getAmzAuditLogMetadata, setAmzAuditLogMetadata) +import qualified Data.Aeson as A +import qualified Data.ByteString as BS +import qualified Data.ByteString.Lazy as LBS +import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Imports +import Test.Tasty +import Test.Tasty.QuickCheck as QC + +tests :: TestTree +tests = + testGroup + "CargoHold.S3" + [ QC.testProperty + "audit-log metadata header is ASCII for filenames with umlauts" + propAuditLogMetadataHeaderIsAscii, + QC.testProperty + "audit-log metadata percent-encode/decode roundtrips for non-ASCII filenames" + propAuditLogMetadataPercentRoundtrip, + QC.testProperty + "legacy raw audit-log metadata preserves percent escapes" + propLegacyRawAuditLogMetadataPreservesPercentEscapes + ] + +propAuditLogMetadataPercentRoundtrip :: AssetAuditLogMetadata -> QC.Property +propAuditLogMetadataPercentRoundtrip metadata = + let meta' = metadata {filename = "Mönchsjochhütte"} + (k, v) = setAmzAuditLogMetadata meta' + in getAmzAuditLogMetadata [(k, v)] QC.=== Just meta' + +propAuditLogMetadataHeaderIsAscii :: AssetAuditLogMetadata -> QC.Property +propAuditLogMetadataHeaderIsAscii metadata = + let (_, headerValue) = + setAmzAuditLogMetadata metadata {filename = "Mönchsjochhütte"} + in QC.counterexample ("non-ASCII S3 metadata header: " <> show headerValue) $ + BS.all (< 128) (encodeUtf8 headerValue) + +propLegacyRawAuditLogMetadataPreservesPercentEscapes :: AssetAuditLogMetadata -> QC.Property +propLegacyRawAuditLogMetadataPreservesPercentEscapes metadata = + let expected = metadata {filename = "%2F and %20 stay unchanged"} + rawJSON = decodeUtf8 (LBS.toStrict (A.encode expected)) + in getAmzAuditLogMetadata [("wire-metadata", rawJSON)] QC.=== Just expected diff --git a/services/federator/default.nix b/services/federator/default.nix index fa8eac96bda..e0d3e9d7235 100644 --- a/services/federator/default.nix +++ b/services/federator/default.nix @@ -168,6 +168,7 @@ mkDerivation { ]; testHaskellDepends = [ aeson + async base bytestring bytestring-conversion diff --git a/services/federator/federator.cabal b/services/federator/federator.cabal index 872f9911236..2a8181f5f18 100644 --- a/services/federator/federator.cabal +++ b/services/federator/federator.cabal @@ -380,6 +380,7 @@ test-suite federator-tests build-depends: aeson + , async , base , bytestring , bytestring-conversion diff --git a/services/federator/src/Federator/InternalServer.hs b/services/federator/src/Federator/InternalServer.hs index accd160987d..e57d40354e3 100644 --- a/services/federator/src/Federator/InternalServer.hs +++ b/services/federator/src/Federator/InternalServer.hs @@ -131,4 +131,4 @@ callOutward targetDomain component (RPC path) req cont = do serveOutward :: Env -> Int -> IORef [IO ()] -> IO () serveOutward env port cleanupsRef = do - serveServant @(ToServantApi API) env port cleanupsRef (toServant $ server env._httpManager env._internalPort) + serveServant @(ToServantApi API) env port cleanupsRef (toServant $ server env._httpManager env._externalPort) diff --git a/services/federator/src/Federator/MockServer.hs b/services/federator/src/Federator/MockServer.hs index 828a87bcb1a..31094692c8f 100644 --- a/services/federator/src/Federator/MockServer.hs +++ b/services/federator/src/Federator/MockServer.hs @@ -151,7 +151,7 @@ mockInternalRequest remoteCalls mock targetDomain component (RPC path) req cont if path == "api-version" then pure $ MockResponse Wai.status200 "application/json" (Aeson.encode (VersionInfo mock.versions)) else do - modifyIORef remoteCalls (<> [fedRequest]) + atomicModifyIORef' remoteCalls (\xs -> (xs <> [fedRequest], ())) fromException @MockException . handle (throw . handleException) $ mock.handler fedRequest diff --git a/services/federator/test/unit/Test/Federator/Client.hs b/services/federator/test/unit/Test/Federator/Client.hs index 8bc976fe6a9..0660390b393 100644 --- a/services/federator/test/unit/Test/Federator/Client.hs +++ b/services/federator/test/unit/Test/Federator/Client.hs @@ -35,6 +35,7 @@ module Test.Federator.Client (tests) where +import Control.Concurrent.Async (replicateConcurrently_) import Control.Exception hiding (handle) import Control.Monad.Codensity import Control.Monad.Except @@ -92,6 +93,7 @@ tests = testGroup "HTTP2 client" [ testCase "testResponseHeaders" testResponseHeaders, + testCase "testConcurrentRequestsAllRecorded" testConcurrentRequestsAllRecorded, testCase "testStreaming" testStreaming ] ] @@ -248,6 +250,20 @@ testResponseHeaders = do responseStatusCode resp @?= HTTP.status200 lookup "X-Foo" (toList (responseHeaders resp)) @?= Just "bar" +testConcurrentRequestsAllRecorded :: IO () +testConcurrentRequestsAllRecorded = do + (_, sentRequests) <- + withTempMockFederator def $ \port -> do + let req = + HTTP2.requestBuilder + HTTP.methodPost + "/rpc/target.example.com/brig/test" + [("Wire-Origin-Domain", "origin.example.com"), (federationRequestIdHeaderName, "rid")] + "body" + mgr <- defaultHttp2Manager + replicateConcurrently_ 50 (performHTTP2Request mgr (False, "127.0.0.1", port) req) + length sentRequests @?= 50 + testStreaming :: IO () testStreaming = withInfiniteMockServer $ \port -> do let req = HTTP2.requestBuilder HTTP.methodPost "test" [] mempty diff --git a/services/galley/default.nix b/services/galley/default.nix index 8ab0f4c24c8..82838a7098e 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -7,6 +7,7 @@ , aeson-qq , amazonka , amqp +, arbiter-core , async , base , base64-bytestring @@ -32,7 +33,7 @@ , federator , filepath , galley-types -, hasql-pool +, hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , HsOpenSSL @@ -77,7 +78,6 @@ , sop-core , split , ssl-util -, stm , streaming-commons , string-conversions , tagged @@ -121,6 +121,7 @@ mkDerivation { aeson amazonka amqp + arbiter-core async base bilge @@ -134,7 +135,7 @@ mkDerivation { exceptions extended galley-types - hasql-pool + hasql-resource-pool hs-opentelemetry-instrumentation-wai hs-opentelemetry-sdk HsOpenSSL @@ -162,7 +163,6 @@ mkDerivation { singletons split ssl-util - stm text time tinylog diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 2ecfe746462..c6afea65d94 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -98,12 +98,10 @@ library Galley.API.Teams.Notifications Galley.App Galley.Cassandra - Galley.Effects.Queue Galley.Env Galley.External.LegalHoldService Galley.External.LegalHoldService.Internal Galley.Monad - Galley.Queue Galley.Run Galley.Schema.Run Galley.Schema.V100_OutOfSync @@ -197,6 +195,7 @@ library , aeson >=2.0.1.0 , amazonka >=1.4.5 , amqp + , arbiter-core , async >=2.0 , base >=4.6 && <5 , bilge >=0.21.1 @@ -210,7 +209,7 @@ library , exceptions >=0.4 , extended , galley-types >=0.65.0 - , hasql-pool + , hasql-resource-pool , hs-opentelemetry-instrumentation-wai , hs-opentelemetry-sdk , HsOpenSSL >=0.11 @@ -238,7 +237,6 @@ library , singletons , split >=0.2 , ssl-util >=0.1 - , stm >=2.4 , text >=0.11 , time >=1.4 , tinylog >=0.10 diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml index e31a28ce321..85ec6c320c1 100644 --- a/services/galley/galley.integration.yaml +++ b/services/galley/galley.integration.yaml @@ -19,7 +19,6 @@ postgresql: postgresqlPool: size: 20 acquisitionTimeout: 10s - agingTimeout: 1d idlenessTimeout: 10m brig: @@ -94,6 +93,16 @@ settings: meetings: validityPeriod: "5s" + legacyTimeZone: "Europe/Berlin" + pastEditPeriod: "5s" + email: + from: meetings@integration.example.com + replyTo: reply@integration.example.com + transport: + smtpEndpoint: + host: localhost + port: 2525 + smtpConnType: plain # We explicitly do not disable any API version. Please make sure the configuration value is the same in all these configs: # brig, cannon, cargohold, galley, gundeck, proxy, spar. @@ -244,10 +253,6 @@ settings: defaults: status: enabled lockStatus: unlocked - meetingsPremium: - defaults: - status: disabled - lockStatus: locked logLevel: Warn logNetStrings: false diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index a86e6a77f6b..c4bb83e74e9 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -1,4 +1,5 @@ {-# LANGUAGE PartialTypeSignatures #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-partial-type-signatures #-} -- This file is part of the Wire Server implementation. @@ -43,7 +44,6 @@ import Galley.API.Teams qualified as Teams import Galley.API.Teams.Features import Galley.App import Galley.Monad -import Galley.Queue qualified as Q import Galley.Types.Clients import Galley.Types.Error import Imports hiding (head) @@ -77,6 +77,7 @@ import Wire.API.Team.LegalHold (UserLegalHoldStatusEntry (..)) import Wire.API.User (UserIds (cUsers)) import Wire.API.User.Client import Wire.BackendNotificationQueueAccess +import Wire.BoundedQueue.STM qualified as Q import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.ConversationStore hiding (getConversations) import Wire.ConversationStore qualified as ConversationStore diff --git a/services/galley/src/Galley/API/Meetings.hs b/services/galley/src/Galley/API/Meetings.hs index 770d047b41c..f8c832acc8d 100644 --- a/services/galley/src/Galley/API/Meetings.hs +++ b/services/galley/src/Galley/API/Meetings.hs @@ -21,6 +21,10 @@ module Galley.API.Meetings deleteMeeting, getMeeting, listMeetings, + createMeetingV16, + updateMeetingV16, + getMeetingV16, + listMeetingsV16, addMeetingInvitation, removeMeetingInvitation, replaceMeetingInvitation, @@ -40,27 +44,24 @@ import Wire.MeetingsSubsystem qualified as Meetings createMeeting :: (Member Meetings.MeetingsSubsystem r) => Local UserId -> + ConnId -> NewMeeting -> - Sem r Meeting -createMeeting lUser newMeeting = do - (meeting, _conversation) <- Meetings.createMeeting lUser newMeeting - pure meeting + Sem r MeetingWithConversation +createMeeting lUser connId newMeeting = Meetings.createMeeting lUser connId newMeeting updateMeeting :: ( Member Meetings.MeetingsSubsystem r, Member (ErrorS 'MeetingNotFound) r ) => Local UserId -> + ConnId -> Domain -> MeetingId -> UpdateMeeting -> - Sem r Meeting -updateMeeting zUser domain meetingId update = do + Sem r MeetingWithConversation +updateMeeting zUser connId domain meetingId update = do let qMeetingId = Qualified meetingId domain - maybeMeeting <- Meetings.updateMeeting zUser qMeetingId update - case maybeMeeting of - Nothing -> throwS @'MeetingNotFound - Just meeting -> pure meeting + noteS @'MeetingNotFound =<< Meetings.updateMeeting zUser connId qMeetingId update deleteMeeting :: ( Member Meetings.MeetingsSubsystem r, @@ -86,10 +87,7 @@ getMeeting :: Sem r Meeting getMeeting zUser domain meetingId = do let qMeetingId = Qualified meetingId domain - maybeMeeting <- Meetings.getMeeting zUser qMeetingId - case maybeMeeting of - Nothing -> throwS @'MeetingNotFound - Just meeting -> pure meeting + noteS @'MeetingNotFound =<< Meetings.getMeeting zUser qMeetingId listMeetings :: (Member Meetings.MeetingsSubsystem r) => @@ -97,6 +95,46 @@ listMeetings :: Sem r [Meeting] listMeetings lUser = Meetings.listMeetings lUser +createMeetingV16 :: + (Member Meetings.MeetingsSubsystem r) => + Local UserId -> + ConnId -> + NewMeetingV16 -> + Sem r MeetingWithConversationV16 +createMeetingV16 lUser connId newMeeting = Meetings.createMeetingV16 lUser connId newMeeting + +updateMeetingV16 :: + ( Member Meetings.MeetingsSubsystem r, + Member (ErrorS 'MeetingNotFound) r + ) => + Local UserId -> + ConnId -> + Domain -> + MeetingId -> + UpdateMeetingV16 -> + Sem r MeetingWithConversationV16 +updateMeetingV16 zUser connId domain meetingId update = do + let qMeetingId = Qualified meetingId domain + noteS @'MeetingNotFound =<< Meetings.updateMeetingV16 zUser connId qMeetingId update + +getMeetingV16 :: + ( Member Meetings.MeetingsSubsystem r, + Member (ErrorS 'MeetingNotFound) r + ) => + Local UserId -> + Domain -> + MeetingId -> + Sem r MeetingV16 +getMeetingV16 zUser domain meetingId = do + let qMeetingId = Qualified meetingId domain + noteS @'MeetingNotFound =<< Meetings.getMeetingV16 zUser qMeetingId + +listMeetingsV16 :: + (Member Meetings.MeetingsSubsystem r) => + Local UserId -> + Sem r [MeetingV16] +listMeetingsV16 lUser = Meetings.listMeetingsV16 lUser + addMeetingInvitation :: ( Member Meetings.MeetingsSubsystem r, Member (ErrorS 'MeetingNotFound) r diff --git a/services/galley/src/Galley/API/Public/Conversation.hs b/services/galley/src/Galley/API/Public/Conversation.hs index 55c7b16d4fd..ab42b7422c3 100644 --- a/services/galley/src/Galley/API/Public/Conversation.hs +++ b/services/galley/src/Galley/API/Public/Conversation.hs @@ -20,7 +20,10 @@ module Galley.API.Public.Conversation where import Data.Qualified import Galley.App import Imports -import Wire.API.Conversation +import Polysemy +import Wire.API.Conversation hiding (Member) +import Wire.API.Error +import Wire.API.Error.Galley import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Conversation import Wire.ConversationStore.MLS.Types @@ -28,19 +31,19 @@ import Wire.ConversationSubsystem conversationAPI :: API ConversationAPI GalleyEffects conversationAPI = - mkNamedAPI @"get-unqualified-conversation" (\lusr cnv -> toLegacyOwnConversation <$> getUnqualifiedOwnConversation lusr cnv) - <@> mkNamedAPI @"get-unqualified-conversation-legalhold-alias" (\lusr cnv -> toLegacyOwnConversation <$> getUnqualifiedOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v2" (\lusr cnv -> toLegacyOwnConversation <$> getOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v5" (\lusr cnv -> toLegacyOwnConversation <$> getOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v9" (\lusr cnv -> toLegacyOwnConversation <$> getOwnConversation lusr cnv) - <@> mkNamedAPI @"get-conversation@v15" (\lusr cnv -> toLegacyConversation <$> getConversation lusr cnv) + mkNamedAPI @"get-unqualified-conversation" (\lusr cnv -> legacyOwnConversation =<< getUnqualifiedOwnConversation lusr cnv) + <@> mkNamedAPI @"get-unqualified-conversation-legalhold-alias" (\lusr cnv -> legacyOwnConversation =<< getUnqualifiedOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v2" (\lusr cnv -> legacyOwnConversation =<< getOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v5" (\lusr cnv -> legacyOwnConversation =<< getOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v9" (\lusr cnv -> legacyOwnConversation =<< getOwnConversation lusr cnv) + <@> mkNamedAPI @"get-conversation@v15" (\lusr cnv -> legacyConversation =<< getConversation lusr cnv) <@> mkNamedAPI @"get-conversation" getConversation <@> mkNamedAPI @"get-conversation-roles" getConversationRoles <@> mkNamedAPI @"get-group-info" getGroupInfo <@> mkNamedAPI @"list-conversation-ids-unqualified" conversationIdsPageFromUnqualified <@> mkNamedAPI @"list-conversation-ids-v2" (conversationIdsPaginated DoNotListGlobalSelf) <@> mkNamedAPI @"list-conversation-ids" conversationIdsPageFrom - <@> mkNamedAPI @"get-conversations" (\lusr mids mstart msize -> (\cl -> ConversationList (map toLegacyOwnConversation cl.convList) cl.convHasMore) <$> getPaginatedConversations lusr mids mstart msize) + <@> mkNamedAPI @"get-conversations" (\lusr mids mstart msize -> (\cl -> ConversationList (map toLegacyOwnConversation (filter (not . isMeetingConversation) cl.convList)) cl.convHasMore) <$> getPaginatedConversations lusr mids mstart msize) <@> mkNamedAPI @"list-conversations@v1" (\lusr req -> toLegacyConversationsResponse <$> listConversations lusr req) <@> mkNamedAPI @"list-conversations@v2" (\lusr req -> toLegacyConversationsResponse <$> listConversations lusr req) <@> mkNamedAPI @"list-conversations@v5" (\lusr req -> toLegacyConversationsResponse <$> listConversations lusr req) @@ -75,7 +78,8 @@ conversationAPI = <@> mkNamedAPI @"add-members-to-conversation-unqualified" (\lusr con cnv invite -> addMembers lusr con (tUntagged (qualifyAs lusr cnv)) (InviteQualified (fmap (tUntagged . qualifyAs lusr) (invUsers invite)) (invRoleName invite))) <@> mkNamedAPI @"add-members-to-conversation-unqualified2" addQualifiedMembersUnqualified <@> mkNamedAPI @"add-members-to-conversation" addMembers - <@> mkNamedAPI @"replace-members-in-conversation" replaceMembers + <@> mkNamedAPI @"replace-members-in-conversation@v16" (replaceMembers RemoveMemberLegacyResponse) + <@> mkNamedAPI @"replace-members-in-conversation" (replaceMembers RemoveMemberEligibleMembersResponse) <@> mkNamedAPI @"join-conversation-by-id-unqualified" joinConversationById <@> mkNamedAPI @"join-conversation-by-code-unqualified" joinConversationByReusableCode <@> mkNamedAPI @"code-check" checkReusableCode @@ -115,3 +119,23 @@ toLegacyCGRV9 :: toLegacyCGRV9 = \case GroupConversationExistedV9 conv -> GroupConversationExistedV9 (toLegacyOwnConversation conv) GroupConversationCreatedV9 cgoc -> GroupConversationCreatedV9 (toLegacyCreateGroupOwnConversation cgoc) + +-- | Convert an own-conversation to the legacy (< V16) wire type, hiding meeting +-- conversations entirely (they have no legacy representation): a meeting yields +-- 'ConvNotFound' (404) rather than leaking @group_conv_type: null@. (WPB-26626) +legacyOwnConversation :: + (Member (ErrorS 'ConvNotFound) r) => + OwnConversation GroupConvType -> + Sem r (OwnConversation GroupConvTypeLegacy) +legacyOwnConversation conv = do + when (isMeetingConversation conv) $ throwS @'ConvNotFound + pure (toLegacyOwnConversation conv) + +-- | As 'legacyOwnConversation', for the full 'Conversation' view (V10-V15 routes). +legacyConversation :: + (Member (ErrorS 'ConvNotFound) r) => + Conversation GroupConvType -> + Sem r (Conversation GroupConvTypeLegacy) +legacyConversation conv = do + when (conv.metadata.cnvmGroupConvType == Just MeetingConversation) $ throwS @'ConvNotFound + pure (toLegacyConversation conv) diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs index 2bf0b3ab1e2..1b029a63144 100644 --- a/services/galley/src/Galley/API/Public/Feature.hs +++ b/services/galley/src/Galley/API/Public/Feature.hs @@ -1,4 +1,5 @@ {-# LANGUAGE PartialTypeSignatures #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-partial-type-signatures #-} -- This file is part of the Wire Server implementation. @@ -70,7 +71,7 @@ featureAPI = <@> featureAPIGetPut <@> mkNamedAPI @'("get", PreventAdminlessGroupsConfig) getFeature <@> mkNamedAPI @"put-PreventAdminlessGroupsConfig@v16" setFeature - <@> mkNamedAPI @"put-PreventAdminlessGroupsConfig@v17" setFeature + <@> mkNamedAPI @"put-PreventAdminlessGroupsConfig@v18" setFeature <@> mkNamedAPI @'("get", CellsConfig) getFeature <@> mkNamedAPI @"put-CellsConfig@v13" setFeature <@> mkNamedAPI @'("put", CellsConfig) setFeature @@ -83,8 +84,9 @@ featureAPI = <@> mkNamedAPI @'("get", StealthUsersConfig) getFeature <@> mkNamedAPI @'("get", CellsInternalConfig) getFeature <@> featureAPIGetPut @MeetingsConfig - <@> featureAPIGetPut @MeetingsPremiumConfig - <@> featureAPIGetPut @BackgroundEffectsConfig + <@> mkNamedAPI @'("get", MeetingsPremiumConfig) getFeature + <@> mkNamedAPI @'("put", MeetingsPremiumConfig) setFeature + <@> hoistAPI id featureAPIGetPut deprecatedFeatureConfigAPI :: API DeprecatedFeatureAPI GalleyEffects deprecatedFeatureConfigAPI = diff --git a/services/galley/src/Galley/API/Public/Meetings.hs b/services/galley/src/Galley/API/Public/Meetings.hs index 41aacbc8c0a..e0eef4c8a47 100644 --- a/services/galley/src/Galley/API/Public/Meetings.hs +++ b/services/galley/src/Galley/API/Public/Meetings.hs @@ -24,10 +24,14 @@ import Wire.API.Routes.Public.Galley.Meetings meetingsAPI :: API MeetingsAPI GalleyEffects meetingsAPI = - mkNamedAPI @"create-meeting" Meetings.createMeeting + mkNamedAPI @"create-meeting@v15" Meetings.createMeetingV16 + <@> mkNamedAPI @"create-meeting" Meetings.createMeeting + <@> mkNamedAPI @"update-meeting@v15" Meetings.updateMeetingV16 <@> mkNamedAPI @"update-meeting" Meetings.updateMeeting <@> mkNamedAPI @"delete-meeting" Meetings.deleteMeeting + <@> mkNamedAPI @"get-meeting@v15" Meetings.getMeetingV16 <@> mkNamedAPI @"get-meeting" Meetings.getMeeting + <@> mkNamedAPI @"list-meetings@v16" Meetings.listMeetingsV16 <@> mkNamedAPI @"list-meetings" Meetings.listMeetings <@> mkNamedAPI @"add-meeting-invitation" Meetings.addMeetingInvitation <@> mkNamedAPI @"remove-meeting-invitation" Meetings.removeMeetingInvitation diff --git a/services/galley/src/Galley/API/Public/TeamMember.hs b/services/galley/src/Galley/API/Public/TeamMember.hs index d85d89f514e..5c56816012e 100644 --- a/services/galley/src/Galley/API/Public/TeamMember.hs +++ b/services/galley/src/Galley/API/Public/TeamMember.hs @@ -22,6 +22,8 @@ import Galley.API.Teams.Export qualified as Export import Galley.App import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.TeamMember +import Wire.API.Team.Collaborator +import Wire.TeamCollaboratorsSubsystem teamMemberAPI :: API TeamMemberAPI GalleyEffects teamMemberAPI = @@ -33,5 +35,8 @@ teamMemberAPI = <@> mkNamedAPI @"delete-non-binding-team-member" deleteNonBindingTeamMember <@> mkNamedAPI @"update-team-member" updateTeamMember <@> mkNamedAPI @"get-team-members-csv" Export.getTeamMembersCSV + <@> mkNamedAPI @"add-team-collaborator" + (\zuid tid (NewTeamCollaborator uid perms) -> createTeamCollaborator zuid uid tid perms) + <@> mkNamedAPI @"get-team-collaborators" getAllTeamCollaborators <@> mkNamedAPI @"update-team-collaborator" updateTeamCollaborator <@> mkNamedAPI @"remove-team-collaborator" removeTeamCollaborator diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 7ef65fa3cda..181a720ae8f 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -77,7 +77,6 @@ import Data.Time.Clock (UTCTime) import Galley.API.LegalHold.Team import Galley.API.Teams.Notifications qualified as APITeamQueue import Galley.App -import Galley.Effects.Queue qualified as E import Galley.Types.Error as Galley import Imports hiding (forkIO) import Polysemy @@ -112,6 +111,7 @@ import Wire.API.Team.SearchVisibility qualified as Public import Wire.API.Team.Size import Wire.API.User qualified as U import Wire.API.User.Search +import Wire.BoundedQueue qualified as E import Wire.BrigAPIAccess import Wire.BrigAPIAccess qualified as Brig import Wire.BrigAPIAccess qualified as E @@ -141,7 +141,7 @@ import Wire.Util getTeamH :: forall r. ( Member (ErrorS 'TeamNotFound) r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member TeamStore r, Member TeamSubsystem r ) => @@ -187,7 +187,7 @@ getTeamNameInternal = fmap (fmap TeamName) . E.getTeamName -- one.) getManyTeams :: ( Member TeamStore r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member (ListItems LegacyPaging TeamId) r, Member TeamSubsystem r ) => @@ -200,7 +200,7 @@ getManyTeams zusr = lookupTeam :: ( Member TeamStore r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member TeamSubsystem r ) => UserId -> @@ -329,7 +329,7 @@ deleteTeam :: Member (ErrorS 'TeamNotFound) r, Member (ErrorS OperationDenied) r, Member (Error AuthenticationError) r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member (ErrorS NotATeamMember) r, Member TeamStore r, Member TeamSubsystem r, @@ -361,7 +361,7 @@ internalDeleteBindingTeam :: Member (ErrorS 'TeamNotFound) r, Member (ErrorS 'NotAOneMemberTeam) r, Member (ErrorS 'DeleteQueueFull) r, - Member (E.Queue DeleteItem) r, + Member (E.BoundedQueue DeleteItem) r, Member TeamStore r, Member TeamSubsystem r ) => @@ -1205,7 +1205,7 @@ userIsTeamOwner tid uid = do -- Queues a team for async deletion queueTeamDeletion :: ( Member (ErrorS 'DeleteQueueFull) r, - Member (E.Queue DeleteItem) r + Member (E.BoundedQueue DeleteItem) r ) => TeamId -> UserId -> diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index c87f0651ae1..84d863fa91d 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -1,5 +1,6 @@ {-# LANGUAGE UndecidableSuperClasses #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} -- This file is part of the Wire Server implementation. -- @@ -36,7 +37,7 @@ import Data.Default import Data.Id import Data.Json.Util import Data.Kind -import Data.Qualified (Local) +import Data.Qualified import Galley.API.LegalHold qualified as LegalHold import Galley.API.LegalHold.Team qualified as LegalHold import Galley.App @@ -71,6 +72,7 @@ import Wire.FeaturesConfigSubsystem.Utils (resolveServerFeature) import Wire.FederationAPIAccess (FederationAPIAccess) import Wire.FederationSubsystem (FederationSubsystem) import Wire.FireAndForget +import Wire.JobSubsystem (JobSubsystem, cancelAdminlessJobsForTeam, scheduleAdminlessSetupJob) import Wire.LegalHoldStore (LegalHoldStore) import Wire.NotificationSubsystem import Wire.Options.Galley @@ -86,6 +88,7 @@ import Wire.TeamStore (TeamStore) import Wire.TeamStore qualified as SearchVisibilityData import Wire.TeamSubsystem (TeamSubsystem) import Wire.TeamSubsystem qualified as TeamSubsystem +import Wire.Util type ComputeFeatureConstraints cfg r = (Member FeaturesConfigSubsystem r) @@ -113,6 +116,7 @@ patchFeatureInternal tid patch = do prepareFeature tid patchedFeature patchDbFeature tid patch (returnedFeature :: LockableFeature cfg) <- getFeatureForTeam tid + afterFeatureSet @cfg Nothing tid dbFeatureWithDefaults returnedFeature pushFeatureEvent @cfg tid (mkUpdateEvent tid returnedFeature) pure returnedFeature where @@ -136,16 +140,18 @@ setFeature :: Member TeamFeatureStore r, Member P.TinyLog r, Member NotificationSubsystem r, - Member TeamSubsystem r + Member TeamSubsystem r, + Member (Input (Local ())) r ) => UserId -> TeamId -> Feature cfg -> Sem r (LockableFeature cfg) setFeature uid tid feat = do + lusr <- qualifyLocal uid zusrMembership <- TeamSubsystem.internalGetTeamMember uid tid void $ TeamSubsystem.permissionCheck ChangeTeamFeature zusrMembership - setFeatureUnchecked tid feat + setFeatureUnchecked (Just lusr) tid feat setFeatureInternal :: forall cfg r. @@ -164,7 +170,7 @@ setFeatureInternal :: Sem r (LockableFeature cfg) setFeatureInternal tid feat = do TeamSubsystem.assertTeamExists tid - setFeatureUnchecked tid feat + setFeatureUnchecked Nothing tid feat setFeatureUnchecked :: forall cfg r. @@ -178,13 +184,14 @@ setFeatureUnchecked :: Member NotificationSubsystem r, Member TeamSubsystem r ) => + Maybe (Local UserId) -> TeamId -> Feature cfg -> Sem r (LockableFeature cfg) -setFeatureUnchecked tid feat = do +setFeatureUnchecked originUser tid feat = do (feat0 :: LockableFeature cfg) <- getFeatureForTeam tid guardLockStatus feat0.lockStatus - setFeatureForTeam @cfg tid (withLockStatus feat0.lockStatus feat) + setFeatureForTeam @cfg originUser tid feat0 (withLockStatus feat0.lockStatus feat) updateLockStatus :: forall cfg r. @@ -257,12 +264,15 @@ setFeatureForTeam :: Member TeamFeatureStore r, Member TeamSubsystem r ) => + Maybe (Local UserId) -> TeamId -> LockableFeature cfg -> + LockableFeature cfg -> Sem r (LockableFeature cfg) -setFeatureForTeam tid feat = do +setFeatureForTeam originUser tid oldFeature feat = do prepareFeature tid feat newFeat <- persistFeature tid feat + afterFeatureSet @cfg originUser tid oldFeature newFeat pushFeatureEvent @cfg tid (mkUpdateEvent tid newFeat) pure newFeat @@ -288,6 +298,21 @@ class (GetFeatureConfig cfg) => SetFeatureConfig cfg where default prepareFeature :: TeamId -> LockableFeature cfg -> Sem r () prepareFeature _tid _feat = pure () + afterFeatureSet :: + (SetFeatureForTeamConstraints cfg r) => + Maybe (Local UserId) -> + TeamId -> + LockableFeature cfg -> + LockableFeature cfg -> + Sem r () + default afterFeatureSet :: + Maybe (Local UserId) -> + TeamId -> + LockableFeature cfg -> + LockableFeature cfg -> + Sem r () + afterFeatureSet _originUser _tid _oldFeature _newFeature = pure () + instance SetFeatureConfig SSOConfig where type SetFeatureForTeamConstraints SSOConfig (r :: EffectRow) = @@ -419,7 +444,21 @@ instance SetFeatureConfig MLSConfig where instance SetFeatureConfig ChannelsConfig -instance SetFeatureConfig PreventAdminlessGroupsConfig +instance SetFeatureConfig PreventAdminlessGroupsConfig where + type SetFeatureForTeamConstraints PreventAdminlessGroupsConfig r = Member JobSubsystem r + + afterFeatureSet originUser tid oldFeature newFeature = + case (oldFeature.status, newFeature.status) of + (FeatureStatusDisabled, FeatureStatusEnabled) -> + scheduleAdminlessSetupJob originUser tid + (FeatureStatusEnabled, FeatureStatusDisabled) -> + cancelAdminlessJobsForTeam tid + (FeatureStatusEnabled, FeatureStatusEnabled) + | oldFeature.config /= newFeature.config -> do + cancelAdminlessJobsForTeam tid + scheduleAdminlessSetupJob originUser tid + | otherwise -> pure () + _ -> pure () instance SetFeatureConfig ExposeInvitationURLsToTeamAdminConfig @@ -490,23 +529,21 @@ instance SetFeatureConfig AppsConfig where SetFeatureForTeamConstraints AppsConfig (r :: EffectRow) = (Member BrigAPIAccess r) - prepareFeature tid feat = do - let newStatus = case feat.status of - FeatureStatusEnabled -> Active - FeatureStatusDisabled -> Suspended - appIds <- getAppIdsForTeam tid - -- NB: this will work as long as the only reason for suspending - -- apps is "payment plan expired", but should we ever introduce a - -- suspend button for team admins to let them temporarily disable - -- apps without deinstalling them, then we need to keep track of - -- the suspend reason and filter for the right one here. - -- - -- NB(2): this is not terribly efficient, but it's a rarely called - -- operation with usually small numbers of apps. tweak - -- opportunities: (a) only call this loop if enablement actually - -- changes; (b) do the loop over all appIds in postgres with one - -- query. - for_ appIds $ \uid -> setAccountStatus uid newStatus + prepareFeature tid feat = case feat.status of + -- WPB-25579: re-enabling the feature must NOT re-activate apps. An app may + -- have been suspended for reasons other than this feature being disabled + -- (e.g. suspended individually), so we never blanket-reactivate here. + FeatureStatusEnabled -> + -- Do nothing. Some apps may have been suspended for reasons + -- unrelated to the feature flag flipping. + pure () + FeatureStatusDisabled -> do + appIds <- getAppIdsForTeam tid + -- NB: this is not terribly efficient, but it's a rarely called operation + -- with usually small numbers of apps. tweak opportunities: (a) only call + -- this loop if enablement actually changes; (b) loop over all + -- appIds in postgres with one query. + for_ appIds $ \uid -> setAccountStatus uid Suspended instance SetFeatureConfig SimplifiedUserConnectionRequestQRCodeConfig diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index b7e256db100..4d755d8c612 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -42,26 +42,27 @@ module Galley.App ) where +import Arbiter.Core qualified as ArbiterCore import Bilge hiding (Request, header, host, options, port, statusCode, statusMessage) import Cassandra hiding (Set) import Cassandra.Util (initCassandraForService) import Control.Error hiding (err) import Control.Lens hiding ((.=)) +import Data.Domain (Domain) import Data.Id import Data.Misc import Data.Qualified import Data.Range import Data.Text qualified as Text -import Galley.Effects.Queue qualified as GE +import Data.Time.Clock (secondsToDiffTime) import Galley.Env import Galley.External.LegalHoldService.Internal qualified as LHInternal import Galley.Monad (runApp) -import Galley.Queue -import Galley.Queue qualified as Q import Galley.Types.Error import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Hasql.Pool qualified as Hasql import Hasql.Pool.Extended (initPostgresPool) +import Hasql.Pool.Extended qualified as HasqlPoolExt import Imports hiding (forkIO) import Network.AMQP.Extended (mkRabbitMqChannelMVar) import Network.HTTP.Client (responseTimeoutMicro) @@ -86,16 +87,20 @@ import UnliftIO.Exception qualified as UnliftIO import Wire.API.Conversation.Config (ConversationSubsystemConfig (..)) import Wire.API.Conversation.Protocol import Wire.API.Error -import Wire.API.Error.Galley (GalleyError (..), NonFederatingBackends, OperationDenied, UnreachableBackends) +import Wire.API.Error.Galley (GalleyError (..), MeetingError, NonFederatingBackends, OperationDenied, UnreachableBackends) import Wire.API.Federation.Client import Wire.API.Federation.Error import Wire.API.MLS.Keys (MLSKeysByPurpose, MLSPrivateKeys) +import Wire.API.Meeting (defaultLegacyTimeZone, parseTimeZone) import Wire.API.Team.Collaborator import Wire.API.Team.Feature import Wire.API.Team.FeatureFlags import Wire.AWS qualified as Aws import Wire.BackendNotificationQueueAccess (BackendNotificationQueueAccess) import Wire.BackendNotificationQueueAccess.RabbitMq qualified as BackendNotificationQueueAccess +import Wire.BoundedQueue (BoundedQueue) +import Wire.BoundedQueue.STM +import Wire.BoundedQueue.STM qualified as Q import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.BrigAPIAccess.Rpc import Wire.CodeStore (CodeStore) @@ -122,6 +127,8 @@ import Wire.FireAndForget import Wire.GundeckAPIAccess (GundeckAPIAccess, runGundeckAPIAccess) import Wire.HashPassword import Wire.HashPassword.Interpreter +import Wire.JobSubsystem (JobSubsystem, JobSubsystemConfig (..)) +import Wire.JobSubsystem.Interpreter (interpretJobSubsystem) import Wire.LegalHoldStore (LegalHoldStore) import Wire.LegalHoldStore.Cassandra (interpretLegalHoldStoreToCassandra) import Wire.LegalHoldStore.Env (LegalHoldEnv (..)) @@ -130,6 +137,8 @@ import Wire.ListItems.Team.Cassandra ( interpretInternalTeamListToCassandra, interpretTeamListToCassandra, ) +import Wire.MeetingNotifier (MeetingNotifier) +import Wire.MeetingNotifier.Interpreter (interpretMeetingNotifier) import Wire.MeetingsStore (MeetingsStore) import Wire.MeetingsStore.Postgres (interpretMeetingsStoreToPostgres) import Wire.MeetingsSubsystem (MeetingsSubsystem) @@ -189,6 +198,9 @@ import Wire.UserGroupStore.Postgres (interpretUserGroupStoreToPostgres) type GalleyEffects = '[ MeetingsSubsystem, ConversationSubsystem, + MeetingNotifier, + JobSubsystem, + Input RequestId, FederationSubsystem, TeamCollaboratorsSubsystem, Input AllTeamFeatures, @@ -236,10 +248,10 @@ type GalleyEffects = Input (Maybe (MLSKeysByPurpose MLSPrivateKeys)), Input (Maybe GroupInfoCheckEnabled), Input Opts, - Input (Either HttpsUrl (Map Text HttpsUrl)), + Input (Either HttpsUrl (Map Domain HttpsUrl)), Now, - GE.Queue DeleteItem, - Error Meeting.MeetingError, + BoundedQueue DeleteItem, + Error MeetingError, Error DynError, Error RateLimitExceeded, Error ConversationSubsystemError, @@ -266,7 +278,7 @@ type GalleyEffects = ErrorS 'InvalidOperation, Error RpcException, Input ClientState, - Input Hasql.Pool, + Input HasqlPoolExt.Pool, Input Env, Input ConversationSubsystemConfig, Error MigrationLockError, @@ -293,8 +305,20 @@ type GalleyEffects = Final IO ] +-- | Resolved meeting system config, with defaults 48h validity / 24h +-- past-edit period. +meetingSystemConfig :: Opts -> Meeting.MeetingSystemConfig +meetingSystemConfig o = + Meeting.MeetingSystemConfig + { legacyTimeZone = fromMaybe defaultLegacyTimeZone (m >>= view legacyTimeZone >>= parseTimeZone), + validityPeriod = realToFrac $ maybe (secondsToDiffTime (48 * 3600)) (.duration) (m >>= view validityPeriod), + pastEditPeriod = realToFrac $ maybe (secondsToDiffTime (24 * 3600)) (.duration) (m >>= view pastEditPeriod) + } + where + m = o ^. settings . meetings + -- Define some invariants for the options used -validateOptions :: Opts -> IO (Either HttpsUrl (Map Text HttpsUrl)) +validateOptions :: Opts -> IO (Either HttpsUrl (Map Domain HttpsUrl)) validateOptions o = do let settings' = view settings o optFanoutLimit = fromIntegral . fromRange $ currentFanoutLimit settings'._maxTeamSize settings'._maxFanoutSize @@ -302,6 +326,9 @@ validateOptions o = do error "setMaxConvSize cannot be > setTruncationLimit" when (settings' ^. maxTeamSize < optFanoutLimit) $ error "setMaxTeamSize cannot be < setTruncationLimit" + let meetingsCfg = meetingSystemConfig o + when (meetingsCfg.pastEditPeriod < 0 || meetingsCfg.pastEditPeriod > meetingsCfg.validityPeriod) $ + error "settings.meetings.pastEditPeriod must be non-negative and cannot be greater than settings.meetings.validityPeriod" case (o ^. O.federator, o ^. rabbitmq) of (Nothing, Just _) -> error "RabbitMQ config is specified and federator is not, please specify both or none" (Just _, Nothing) -> error "Federator is specified and RabbitMQ config is not, please specify both or none" @@ -496,8 +523,8 @@ evalGalley e = . mapError toResponse -- Error ConversationSubsystemError, . mapError rateLimitExceededToHttpError . mapError toResponse -- DynError - . mapError meetingError - . interpretQueue (e ^. deleteQueue) + . mapError toResponse -- Error MeetingError + . interpretBoundedQueue (e ^. deleteQueue) . nowToIO . runInputConst (e ^. convCodeURI) . runInputConst (e ^. options) @@ -547,11 +574,15 @@ evalGalley e = . runInputSem getAllTeamFeaturesForServer . interpretTeamCollaboratorsSubsystem . runFederationSubsystem conversationSubsystemConfig.federationProtocols + . runInputConst (e ^. reqId) + . interpretJobSubsystem + JobSubsystemConfig + { jobSubsystemSchemaName = ArbiterCore.defaultSchemaName + } + . interpretMeetingNotifier . interpretConversationSubsystem - . Meeting.interpretMeetingsSubsystem meetingValidityPeriod + . Meeting.interpretMeetingsSubsystem (meetingSystemConfig (e ^. options)) where - meetingValidityPeriod = - realToFrac $ maybe (48 * 3600) (.duration) (e ^. options . settings . meetings >>= view validityPeriod) lh = view (options . settings . featureFlags . to npProject) e legalHoldEnv = let makeReq fpr url rb = runApp e (LHInternal.makeVerifiedRequest fpr url rb) @@ -565,10 +596,3 @@ interpretTeamFeatureSpecialContext e = mapTeamFeatureStoreError :: TeamFeatureStoreError -> InternalError mapTeamFeatureStoreError (TeamFeatureStoreErrorInternalError msg) = InternalErrorWithDescription msg - -meetingError :: Meeting.MeetingError -> Servant.Tagged 'InvalidOperation () -meetingError = - \case - Meeting.InvalidTimes -> Servant.Tagged @'InvalidOperation () - Meeting.EmptyUpdate -> Servant.Tagged @'InvalidOperation () - Meeting.MeetingsFeatureDisabled -> Servant.Tagged @'InvalidOperation () diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index 009ac952546..01d18b7a507 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -45,12 +45,12 @@ where import Cassandra import Control.Lens hiding ((.=)) +import Data.Domain (Domain) import Data.Id import Data.Misc (HttpsUrl) import Data.Time.Clock.DiffTime (millisecondsToDiffTime) -import Galley.Queue qualified as Q import HTTP2.Client.Manager (Http2Manager) -import Hasql.Pool +import Hasql.Pool.Extended import Imports import Network.AMQP qualified as Q import Network.HTTP.Client @@ -60,6 +60,7 @@ import Wire.API.MLS.Keys import Wire.API.Team.FeatureFlags (FanoutLimit) import Wire.API.Team.FeatureFlags qualified as FeatureFlags import Wire.AWS qualified as Aws +import Wire.BoundedQueue.STM qualified as Q import Wire.ExternalAccess.External import Wire.NotificationSubsystem.Interpreter import Wire.Options.Galley @@ -85,7 +86,7 @@ data Env = Env _aEnv :: Maybe Aws.Env, _mlsKeys :: Maybe (MLSKeysByPurpose MLSPrivateKeys), _rabbitmqChannel :: Maybe (MVar Q.Channel), - _convCodeURI :: Either HttpsUrl (Map Text HttpsUrl), + _convCodeURI :: Either HttpsUrl (Map Domain HttpsUrl), _passwordHashingRateLimitEnv :: RateLimitEnv } diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index be55ed36d83..418c285b8df 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -23,6 +23,7 @@ where import AWS.Util (readAuthExpiration) import Amazonka qualified as AWS +import Arbiter.Core qualified as ArbiterCore import Cassandra (runClient, shutdown) import Cassandra.Schema (versionCheck) import Control.Concurrent.Async qualified as Async @@ -44,7 +45,7 @@ import Galley.App qualified as App import Galley.Cassandra import Galley.Env import Galley.Monad -import Galley.Queue qualified as Q +import Hasql.Pool.Extended (rawPool) import Imports import Network.HTTP.Media.RenderHeader qualified as HTTPMedia import Network.HTTP.Types qualified as HTTP @@ -66,6 +67,8 @@ import Wire.API.Routes.Public.Galley import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import Wire.AWS (awsEnv) +import Wire.BoundedQueue.STM qualified as Q +import Wire.JobSubsystem.Migrations (mkArbiterConnectionString, runJobMigrations) import Wire.OpenTelemetry (withTracerC) import Wire.Options.Galley import Wire.PostgresMigrations (runAllMigrations) @@ -74,7 +77,13 @@ run :: Opts -> IO () run opts = lowerCodensity do tracer <- withTracerC (app, env) <- mkApp opts - lift $ runAllMigrations env._hasqlPool env._applog + lift $ runAllMigrations env._hasqlPool.rawPool env._applog + arbiterConnStr <- + lift $ + mkArbiterConnectionString + (opts ^. postgresql) + (opts ^. postgresqlPassword) + lift $ runJobMigrations arbiterConnStr ArbiterCore.defaultSchemaName let settings' = newSettings $ defaultServer diff --git a/services/galley/test/integration/API/Teams.hs b/services/galley/test/integration/API/Teams.hs index 11e7f983bab..b95aa7a12ed 100644 --- a/services/galley/test/integration/API/Teams.hs +++ b/services/galley/test/integration/API/Teams.hs @@ -106,9 +106,6 @@ tests s = [test s "the list should be truncated" testUncheckedListTeamMembers], test s "enable/disable SSO" testEnableSSOPerTeam, test s "enable/disable Custom Search Visibility" testEnableTeamSearchVisibilityPerTeam, - test s "create 1-1 conversation between non-team members (fail)" testCreateOne2OneFailForNonTeamMembers, - test s "create 1-1 conversation between binding team members" (testCreateOne2OneWithMembers RoleMember), - test s "create 1-1 conversation between binding team members as partner" (testCreateOne2OneWithMembers RoleExternalPartner), test s "poll team-level event queue" testTeamQueue, test s "add new team member internal" testAddTeamMemberInternal, test s "remove aka delete team member (binding, owner has passwd)" (testRemoveBindingTeamMember True), @@ -379,45 +376,6 @@ testEnableTeamSearchVisibilityPerTeam = do Util.putTeamSearchVisibilityAvailableInternal tid FeatureStatusDisabled getSearchVisibilityCheck SearchVisibilityStandard -testCreateOne2OneFailForNonTeamMembers :: TestM () -testCreateOne2OneFailForNonTeamMembers = do - owner <- Util.randomUser - let p1 = Util.symmPermissions [CreateConversation, AddRemoveConvMember] - let p2 = Util.symmPermissions [CreateConversation, AddRemoveConvMember, AddTeamMember] - mem1 <- newTeamMember' p1 <$> Util.randomUser - mem2 <- newTeamMember' p2 <$> Util.randomUser - Util.connectUsers owner ((mem1 ^. userId) :| [mem2 ^. userId]) - -- Both have a binding team but not the same team - owner1 <- Util.randomUser - tid1 <- Util.createBindingTeamInternal "foo" owner1 - assertTeamActivate "create team" tid1 - owner2 <- Util.randomUser - tid2 <- Util.createBindingTeamInternal "foo" owner2 - assertTeamActivate "create another team" tid2 - Util.createOne2OneTeamConv owner1 owner2 Nothing tid1 !!! do - const 403 === statusCode - const "non-binding-team-members" === (Error.label . responseJsonUnsafeWithMsg "error label") - -testCreateOne2OneWithMembers :: - (HasCallStack) => - -- | Role of the user who creates the conversation - Role -> - TestM () -testCreateOne2OneWithMembers (rolePermissions -> perms) = do - c <- view tsCannon - (owner, tid) <- Util.createBindingTeam - mem1 <- newTeamMember' perms <$> Util.randomUser - WS.bracketR c (mem1 ^. userId) $ \wsMem1 -> do - Util.addTeamMemberInternal tid (mem1 ^. userId) (mem1 ^. permissions) (mem1 ^. invitation) - checkTeamMemberJoin tid (mem1 ^. userId) wsMem1 - assertTeamUpdate "team member join" tid 2 [owner] - void $ retryWhileN 10 repeatIf (Util.createOne2OneTeamConv owner (mem1 ^. userId) Nothing tid) - -- Recreating a One2One is a no-op, returns a 200 - Util.createOne2OneTeamConv owner (mem1 ^. userId) Nothing tid !!! const 200 === statusCode - where - repeatIf :: ResponseLBS -> Bool - repeatIf r = statusCode r /= 201 - -- | At the time of writing this test, the only event sent to this queue is 'MemberJoin'. testTeamQueue :: TestM () testTeamQueue = do @@ -484,7 +442,18 @@ testAddTeamMemberInternal = do let p1 = Util.symmPermissions [GetBilling] -- permissions are irrelevant on internal endpoint mem1 <- newTeamMember' p1 <$> Util.randomUser WS.bracketRN c [owner, mem1 ^. userId] $ \[wsOwner, wsMem1] -> do - Util.addTeamMemberInternal tid (mem1 ^. userId) (mem1 ^. permissions) (mem1 ^. invitation) + -- This test directly exercises the internal @POST /i/teams/:tid/members@ + -- endpoint (formerly @Util.addTeamMemberInternal@). It is intentionally + -- inlined rather than exposed as a reusable helper because that endpoint + -- bypasses the invitation flow and must not be reused by other tests + -- (https://wearezeta.atlassian.net/browse/SQSERVICES-471). + g <- viewGalley + post + ( g + . paths ["i", "teams", toByteString' tid, "members"] + . json (Member.mkNewTeamMember (mem1 ^. userId) (mem1 ^. permissions) (mem1 ^. invitation)) + ) + !!! const 200 === statusCode liftIO . void $ mapConcurrently (checkJoinEvent tid (mem1 ^. userId)) [wsOwner, wsMem1] assertTeamUpdate "team member join" tid 2 [owner] void $ Util.getTeamMemberInternal tid (mem1 ^. userId) @@ -736,29 +705,26 @@ testAddTeamMemberToConv :: TestM () testAddTeamMemberToConv = do personalUser <- Util.randomUser (ownerT1, qOwnerT1) <- Util.randomUserTuple + (ownerT2, qOwnerT2) <- Util.randomUserTuple + tidT1 <- createBindingTeamInternal "foo" ownerT1 let p = Util.symmPermissions [AddRemoveConvMember] - mem1T1 <- Util.randomUser + pEmpty = Util.symmPermissions [] + mem1T1 <- addMemberWithPermissions ownerT1 tidT1 p qMem1T1 <- Qualified mem1T1 <$> viewFederationDomain - mem2T1 <- Util.randomUser + mem2T1 <- addMemberWithPermissions ownerT1 tidT1 p qMem2T1 <- Qualified mem2T1 <$> viewFederationDomain - - let pEmpty = Util.symmPermissions [] - mem3T1 <- Util.randomUser + mem3T1 <- addMemberWithPermissions ownerT1 tidT1 pEmpty qMem3T1 <- Qualified mem3T1 <$> viewFederationDomain - - mem4T1 <- newTeamMember' pEmpty <$> Util.randomUser - qMem4T1 <- Qualified (mem4T1 ^. userId) <$> viewFederationDomain - (ownerT2, qOwnerT2) <- Util.randomUserTuple - mem1T2 <- newTeamMember' p <$> Util.randomUser - qMem1T2 <- Qualified (mem1T2 ^. userId) <$> viewFederationDomain - Util.connectUsers ownerT1 (mem1T1 :| [mem2T1, mem3T1, ownerT2, personalUser]) - tidT1 <- createBindingTeamInternal "foo" ownerT1 - do - Util.addTeamMemberInternal tidT1 mem1T1 p Nothing - Util.addTeamMemberInternal tidT1 mem2T1 p Nothing - Util.addTeamMemberInternal tidT1 mem3T1 pEmpty Nothing + mem4T1 <- Util.randomUser + qMem4T1 <- Qualified mem4T1 <$> viewFederationDomain tidT2 <- Util.createBindingTeamInternal "foo" ownerT2 - Util.addTeamMemberInternal tidT2 (mem1T2 ^. userId) (mem1T2 ^. permissions) (mem1T2 ^. invitation) + mem1T2 <- addMemberWithPermissions ownerT2 tidT2 p + qMem1T2 <- Qualified mem1T2 <$> viewFederationDomain + -- ownerT1 is connected across teams to ownerT2 and personally to + -- personalUser. Same-team members (mem1T1/mem2T1/mem3T1) need no explicit + -- connection: same-binding-team connection requests are rejected (403), and + -- every assertion involving them is satisfied by the same-team condition. + Util.connectUsers ownerT1 (ownerT2 :| [personalUser]) -- Team owners create new regular team conversation: cidT1 <- Util.createTeamConv ownerT1 tidT1 [] (Just "blaa") Nothing Nothing qcidT1 <- Qualified cidT1 <$> viewFederationDomain @@ -791,7 +757,7 @@ testAddTeamMemberToConv = do Util.assertConvMember qMem1T2 cidT1 -- Still, they cannot add random members without a connection from T1, despite the conversation being "hosted" there Util.postMembers ownerT2 (pure qMem4T1) qcidT1 !!! const 403 === statusCode - Util.assertNotConvMember (mem4T1 ^. userId) cidT1 + Util.assertNotConvMember mem4T1 cidT1 -- Now let's look at convs hosted on team2 -- ownerT2 *is* connected to ownerT1 Util.postMembers ownerT2 (pure qOwnerT1) qcidT2 !!! const 200 === statusCode @@ -799,7 +765,7 @@ testAddTeamMemberToConv = do -- and mem1T2 is on the same team, but mem1T1 is *not* Util.postMembers ownerT2 (qMem1T2 :| [qMem1T1]) qcidT2 !!! const 403 === statusCode Util.assertNotConvMember mem1T1 cidT2 - Util.assertNotConvMember (mem1T2 ^. userId) cidT2 + Util.assertNotConvMember mem1T2 cidT2 -- mem1T2 is on the same team, so that is fine too Util.postMembers ownerT2 (pure qMem1T2) qcidT2 !!! const 200 === statusCode Util.assertConvMember qMem1T2 cidT2 @@ -823,6 +789,12 @@ testAddTeamMemberToConv = do -- Users *can* add across teams if *connected* Util.postMembers ownerT1 (pure qOwnerT2) qcidPersonal !!! const 200 === statusCode Util.assertConvMember qOwnerT2 cidPersonal + where + addMemberWithPermissions :: UserId -> TeamId -> Permissions -> TestM UserId + addMemberWithPermissions inviter tid perms = do + mem <- Util.addUserToTeamWithRole (Just RoleMember) inviter tid + Util.updateTeamMemberPermissions inviter mem tid perms + pure (mem ^. userId) testUpdateTeamConv :: -- | Team role of the user who creates the conversation @@ -1145,9 +1117,9 @@ testDeleteTeamConv = do (tid, owner, _) <- Util.createBindingTeamWithMembers 2 qOwner <- Qualified owner <$> viewFederationDomain let p = Util.symmPermissions [P.DeleteConversation] - member <- newTeamMember' p <$> Util.randomUser + member <- Util.addUserToTeamWithRole (Just RoleMember) owner tid qMember <- Qualified (member ^. userId) <$> viewFederationDomain - Util.addTeamMemberInternal tid (member ^. userId) (member ^. permissions) Nothing + Util.updateTeamMemberPermissions owner member tid p let members = [qOwner, qMember] extern <- Util.randomUser qExtern <- Qualified extern <$> viewFederationDomain diff --git a/services/galley/test/integration/API/Teams/LegalHold.hs b/services/galley/test/integration/API/Teams/LegalHold.hs index db9e3db7d0c..ddb839c296a 100644 --- a/services/galley/test/integration/API/Teams/LegalHold.hs +++ b/services/galley/test/integration/API/Teams/LegalHold.hs @@ -108,8 +108,7 @@ data IsWorking = Working | NotWorking testCreateLegalHoldTeamSettings :: TestM () testCreateLegalHoldTeamSettings = withTeam $ \owner tid -> do putLHWhitelistTeam tid !!! const 200 === statusCode - member <- randomUser - addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing + member <- (^. userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid -- Random port, hopefully nothing is runing here! brokenService <- newLegalHoldService 4242 -- not allowed to create if team is not whitelisted @@ -165,8 +164,8 @@ testCreateLegalHoldTeamSettings = withTeam $ \owner tid -> do testRemoveLegalHoldFromTeam :: TestM () testRemoveLegalHoldFromTeam = do (owner, tid) <- createBindingTeam - member <- randomUser - addTeamMemberInternal tid member noPermissions Nothing + memberTm <- addUserToTeamWithRole (Just RoleMember) owner tid + updateTeamMemberPermissions owner memberTm tid noPermissions -- fails if LH for team is disabled deleteSettings (Just defPassword) owner tid !!! testResponse 403 (Just "legalhold-disable-unimplemented") @@ -180,12 +179,12 @@ testAddTeamUserTooLargeWithLegalholdWhitelisted = withTeam $ \owner tid -> do testCannotCreateLegalHoldDeviceOldAPI :: TestM () testCannotCreateLegalHoldDeviceOldAPI = do - member <- randomUser + nonMember <- randomUser (owner, tid) <- createBindingTeam -- user without team can't add LH device - tryout member + tryout nonMember -- team member can't add LH device - addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing + member <- (^. userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid tryout member -- team owner can't add LH device tryout owner diff --git a/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs b/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs index 734af584d74..580e7d7d8d1 100644 --- a/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs +++ b/services/galley/test/integration/API/Teams/LegalHold/DisabledByDefault.hs @@ -93,8 +93,7 @@ data IsWorking = Working | NotWorking testCreateLegalHoldTeamSettings :: TestM () testCreateLegalHoldTeamSettings = do (owner, tid) <- createBindingTeam - member <- randomUser - addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing + member <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid -- Random port, hopefully nothing is runing here! brokenService <- newLegalHoldService 4242 -- not allowed to create if team setting is disabled @@ -150,8 +149,9 @@ testRemoveLegalHoldFromTeam :: TestM () testRemoveLegalHoldFromTeam = do (owner, tid) <- createBindingTeam stranger <- randomUser - member <- randomUser - addTeamMemberInternal tid member noPermissions Nothing + memberTm <- addUserToTeamWithRole (Just RoleMember) owner tid + updateTeamMemberPermissions owner memberTm tid noPermissions + let member = memberTm ^. Team.userId -- fails if LH for team is disabled deleteSettings (Just defPassword) owner tid !!! testResponse 403 (Just "legalhold-not-enabled") withDummyTestServiceForTeam' owner tid $ \lhPort chan -> do @@ -228,12 +228,12 @@ testAddTeamUserTooLargeWithLegalhold = do testCannotCreateLegalHoldDeviceOldAPI :: TestM () testCannotCreateLegalHoldDeviceOldAPI = do - member <- randomUser + nonMember <- randomUser (owner, tid) <- createBindingTeam -- user without team can't add LH device - tryout member + tryout nonMember -- team member can't add LH device - addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing + member <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid tryout member -- team owner can't add LH device tryout owner @@ -257,8 +257,7 @@ testCannotCreateLegalHoldDeviceOldAPI = do testGetTeamMembersIncludesLHStatus :: TestM () testGetTeamMembersIncludesLHStatus = do (owner, tid) <- createBindingTeam - member <- randomUser - addTeamMemberInternal tid member (rolePermissions RoleMember) Nothing + member <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid let findMemberStatus :: [TeamMember] -> Maybe UserLegalHoldStatus findMemberStatus ms = diff --git a/services/galley/test/integration/API/Util.hs b/services/galley/test/integration/API/Util.hs index bef749c0f54..845a4f54984 100644 --- a/services/galley/test/integration/API/Util.hs +++ b/services/galley/test/integration/API/Util.hs @@ -246,8 +246,7 @@ createBindingTeamWithNMembersWithHandles withHandles n = do (owner, tid) <- createBindingTeam setHandle owner mems <- replicateM n $ do - member1 <- randomUser - addTeamMemberInternal tid member1 (Team.rolePermissions RoleMember) Nothing + member1 <- (^. Team.userId) <$> addUserToTeamWithRole (Just RoleMember) owner tid setHandle member1 pure member1 pure (owner, tid, mems) @@ -410,17 +409,6 @@ getTeamMemberInternal tid mid = do r <- get (g . paths ["i", "teams", toByteString' tid, "members", toByteString' mid]) TeamId -> UserId -> Permissions -> Maybe (UserId, UTCTimeMillis) -> TestM () -addTeamMemberInternal tid muid mperms mmbinv = addTeamMemberInternal' tid muid mperms mmbinv !!! const 200 === statusCode - --- | FUTUREWORK: do not use this, it's broken!! use 'addUserToTeam' instead! https://wearezeta.atlassian.net/browse/SQSERVICES-471 -addTeamMemberInternal' :: (HasCallStack) => TeamId -> UserId -> Permissions -> Maybe (UserId, UTCTimeMillis) -> TestM ResponseLBS -addTeamMemberInternal' tid muid mperms mmbinv = do - g <- viewGalley - let payload = json (mkNewTeamMember muid mperms mmbinv) - post (g . paths ["i", "teams", toByteString' tid, "members"] . payload) - addUserToTeam :: (HasCallStack) => UserId -> TeamId -> TestM TeamMember addUserToTeam = addUserToTeamWithRole Nothing @@ -465,9 +453,23 @@ addUserToTeamWithSSO hasEmail tid = do getTeamMember uid tid uid makeOwner :: (HasCallStack) => UserId -> TeamMember -> TeamId -> TestM () -makeOwner owner mem tid = do +makeOwner owner mem tid = updateTeamMemberPermissions owner mem tid fullPermissions + +-- | Update an existing team member's permissions via the public +-- @PUT /teams/:tid/members@ endpoint. Use this together with +-- 'addUserToTeamWithRole' when a test needs a member with custom +-- 'Permissions' that do not correspond to a 'Role' (the invitation flow +-- only accepts a 'Role'). +updateTeamMemberPermissions :: + (HasCallStack) => + UserId -> + TeamMember -> + TeamId -> + Permissions -> + TestM () +updateTeamMemberPermissions owner mem tid perms = do galley <- viewGalley - let changeMember = mkNewTeamMember (mem ^. Team.userId) fullPermissions (mem ^. Team.invitation) + let changeMember = mkNewTeamMember (mem ^. Team.userId) perms (mem ^. Team.invitation) put ( galley . paths ["teams", toByteString' tid, "members"] @@ -687,29 +689,6 @@ updateTeamConv zusr convid upd = do . json upd ) -createOne2OneTeamConv :: UserId -> UserId -> Maybe Text -> TeamId -> TestM ResponseLBS -createOne2OneTeamConv u1 u2 n tid = do - g <- viewGalley - let conv = - NewConv - [u2] - [] - (n >>= checked) - mempty - Nothing - (Just $ ConvTeamInfo tid) - Nothing - Nothing - roleNameWireAdmin - BaseProtocolProteusTag - GroupConversation - False - Nothing - False - Nothing - def - post $ g . path "/one2one-conversations" . zUser u1 . zConn "conn" . zType "access" . json conv - postConv :: UserId -> [UserId] -> @@ -2615,7 +2594,8 @@ mkProfile quid name = profileSupportedProtocols = defSupportedProtocols, profileType = UserTypeRegular, profileApp = Nothing, - profileSearchable = True + profileSearchable = True, + profileContactStatus = Nothing } -- mock federator diff --git a/services/gundeck/src/Gundeck/ThreadBudget.hs b/services/gundeck/src/Gundeck/ThreadBudget.hs index f226dc379c2..34c41debe3f 100644 --- a/services/gundeck/src/Gundeck/ThreadBudget.hs +++ b/services/gundeck/src/Gundeck/ThreadBudget.hs @@ -15,12 +15,12 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . --- | Like "Brig.Budget", but in-memory, per host (not per service), and with an strict/exact +-- | Like "Wire.BudgetStore", but in-memory, per host (not per service), and with an strict/exact -- upper bound. Like https://hackage.haskell.org/package/token-bucket, but takes the entire -- run-time of the actions into account, not just the number of executions. -- http://hackage.haskell.org/package/rate-limit also looks related. -- https://github.com/juspay/fencer does what this module does, but as a networked service, so --- in that way it works more like "Brig.Budget". +-- in that way it works more like "Wire.BudgetStore". -- -- FUTUREWORK: https://github.com/layer-3-communications/lockpool seems like almost exactly -- the same thing, but I only found this after ThreadBudget was done. Before considering to diff --git a/services/integration.yaml b/services/integration.yaml index 427aa761d1e..2da7e194e1f 100644 --- a/services/integration.yaml +++ b/services/integration.yaml @@ -187,6 +187,13 @@ rabbitmq-v1: adminPort: 15672 vHost: federation-v1 +rabbitmq-v2: + host: localhost + port: 5671 + adminHost: localhost + adminPort: 15672 + vHost: federation-v2 + cassandra: host: 127.0.0.1 port: 9042 diff --git a/services/nginz/integration-test/conf/nginz/nginx.conf b/services/nginz/integration-test/conf/nginz/nginx.conf index 937345a5d9f..a6e10ecf9ea 100644 --- a/services/nginz/integration-test/conf/nginz/nginx.conf +++ b/services/nginz/integration-test/conf/nginz/nginx.conf @@ -416,11 +416,6 @@ http { proxy_pass http://brig; } - location ~* ^(/v[0-9]+)?/teams/([^/]*)/collaborators$ { - include common_response_with_zauth.conf; - proxy_pass http://brig; - } - location ~* ^(/v[0-9]+)?/teams/([^/]*)/apps { include common_response_with_zauth.conf; proxy_pass http://brig; @@ -445,6 +440,16 @@ http { # Galley Endpoints + location ~* ^(/v[0-9]+)?/teams/([^/]*)/collaborators$ { + include common_response_with_zauth.conf; + proxy_pass http://galley; + } + + location ~* ^(/v[0-9]+)?/teams/([^/]*)/collaborators/([^/]*)$ { + include common_response_with_zauth.conf; + proxy_pass http://galley; + } + location ~* ^(/v[0-9]+)?/legalhold/conversations/(.*)$ { include common_response_with_zauth.conf; proxy_pass http://galley; diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 561d20dcb01..5a0fa138ebf 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -51,6 +51,7 @@ library Spar.Schema.V20 Spar.Schema.V21 Spar.Schema.V22 + Spar.Schema.V23 Spar.Schema.V3 Spar.Schema.V4 Spar.Schema.V5 @@ -69,37 +70,17 @@ library Spar.Sem.AssIDStore Spar.Sem.AssIDStore.Cassandra Spar.Sem.AssIDStore.Mem - Spar.Sem.DefaultSsoCode - Spar.Sem.DefaultSsoCode.Cassandra - Spar.Sem.DefaultSsoCode.Mem Spar.Sem.DefaultSsoCode.Spec - Spar.Sem.IdPRawMetadataStore - Spar.Sem.IdPRawMetadataStore.Cassandra - Spar.Sem.IdPRawMetadataStore.Mem Spar.Sem.IdPRawMetadataStore.Spec - Spar.Sem.Reporter - Spar.Sem.Reporter.Wai Spar.Sem.SAML2 Spar.Sem.SAML2.Library - Spar.Sem.SamlProtocolSettings - Spar.Sem.SamlProtocolSettings.Servant Spar.Sem.SAMLUserStore Spar.Sem.SAMLUserStore.Cassandra Spar.Sem.SAMLUserStore.Mem - Spar.Sem.ScimExternalIdStore - Spar.Sem.ScimExternalIdStore.Cassandra - Spar.Sem.ScimExternalIdStore.Mem - Spar.Sem.ScimExternalIdStore.Spec Spar.Sem.ScimTokenStore Spar.Sem.ScimTokenStore.Cassandra Spar.Sem.ScimTokenStore.Mem - Spar.Sem.ScimUserTimesStore - Spar.Sem.ScimUserTimesStore.Cassandra - Spar.Sem.ScimUserTimesStore.Mem Spar.Sem.Utils - Spar.Sem.VerdictFormatStore - Spar.Sem.VerdictFormatStore.Cassandra - Spar.Sem.VerdictFormatStore.Mem other-modules: Paths_spar hs-source-dirs: src @@ -550,6 +531,7 @@ test-suite spec Paths_spar Test.Spar.APISpec Test.Spar.DataSpec + Test.Spar.ErrorSpec Test.Spar.Intra.BrigSpec Test.Spar.Roundtrip.ByteString Test.Spar.Saml.IdPSpec @@ -559,7 +541,6 @@ test-suite spec Test.Spar.Sem.IdPRawMetadataStoreSpec Test.Spar.Sem.NowSpec Test.Spar.Sem.SamlProtocolSettingsSpec - Test.Spar.Sem.ScimExternalIdStoreSpec Test.Spar.TypesSpec hs-source-dirs: test diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index bfe73fcac73..f18e882b496 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -61,15 +61,13 @@ import Control.Lens hiding ((.=)) import qualified Data.ByteString as SBS import Data.ByteString.Builder (toLazyByteString) import Data.ByteString.Conversion -import Data.Domain +import Data.Domain (Domain, domainText) import Data.HavePendingInvitations import Data.Id -import Data.List.NonEmpty (NonEmpty) +import qualified Data.List.NonEmpty as NE import qualified Data.Map as Map import Data.Proxy import Data.Range -import qualified Data.Set as Set -import qualified Data.Text.Encoding as TE import Data.Text.Encoding.Error import qualified Data.Text.Lazy as T import Data.Text.Lazy.Encoding @@ -98,24 +96,12 @@ import Spar.Orphans () import Spar.Scim hiding (handle) import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.AssIDStore (AssIDStore) -import Spar.Sem.DefaultSsoCode (DefaultSsoCode) -import qualified Spar.Sem.DefaultSsoCode as DefaultSsoCode -import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) -import qualified Spar.Sem.IdPRawMetadataStore as IdPRawMetadataStore -import Spar.Sem.Reporter (Reporter) import Spar.Sem.SAML2 (SAML2) import qualified Spar.Sem.SAML2 as SAML2 import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) -import qualified Spar.Sem.SamlProtocolSettings as SamlProtocolSettings -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) -import qualified Spar.Sem.ScimUserTimesStore as ScimUserTimesStore -import Spar.Sem.VerdictFormatStore (VerdictFormatStore) -import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore import System.Logger (Msg) import qualified System.Logger as Log import qualified URI.ByteString as URI @@ -131,17 +117,29 @@ import Wire.API.User.IdentityProvider import Wire.API.User.Saml import Wire.BrigAPIAccess (BrigAPIAccess) import qualified Wire.BrigAPIAccess as BrigAPIAccess +import Wire.DefaultSsoStore (DefaultSsoCode) +import qualified Wire.DefaultSsoStore as DefaultSsoCode import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore, Replaced (..), Replacing (..)) import qualified Wire.IdPConfigStore as IdPConfigStore +import Wire.IdPRawMetadataStore (IdPRawMetadataStore) +import qualified Wire.IdPRawMetadataStore as IdPRawMetadataStore import Wire.IdPSubsystem (IdPSubsystem) import qualified Wire.IdPSubsystem as IdPSubsystem +import Wire.Reporter (Reporter) +import Wire.SamlProtocolSettings (SamlProtocolSettings) +import qualified Wire.SamlProtocolSettings as SamlProtocolSettings +import Wire.ScimExternalIdStore (ScimExternalIdStore) import Wire.ScimSubsystem +import Wire.ScimUserMetaStore (ScimUserMetaStore) +import qualified Wire.ScimUserMetaStore as ScimUserMetaStore import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) import qualified Wire.Sem.Random as Random +import Wire.VerdictFormatStore (VerdictFormatStore) +import qualified Wire.VerdictFormatStore as VerdictFormatStore app :: Env -> Application app ctx0 req cont = do @@ -170,7 +168,7 @@ api :: Member AReqIDStore r, Member VerdictFormatStore r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r, + Member ScimUserMetaStore r, Member ScimTokenStore r, Member ScimSubsystem r, Member IdPSubsystem r, @@ -211,6 +209,7 @@ apiSSO :: Member VerdictFormatStore r, Member AReqIDStore r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member DefaultSsoCode r, Member IdPConfigStore r, Member IdPSubsystem r, @@ -264,7 +263,7 @@ apiINTERNAL :: Member IdPConfigStore r, Member (Error SparError) r, Member SAMLUserStore r, - Member ScimUserTimesStore r, + Member ScimUserMetaStore r, Member (Logger (Msg -> Msg)) r, Member Random r, Member GalleyAPIAccess r, @@ -295,22 +294,20 @@ getMetadata :: Member (Error SparError) r ) => Maybe TeamId -> - Maybe Text -> + Maybe ZHostValue -> Sem r SAML.SPMetadata getMetadata mbTid mbHost = do let err :: Sem r any err = throwSparSem (SparSPNotFound "") - mbHostDom <- (\host -> mkDomain host & either (const err) pure) `mapM` mbHost - let iss :: Sem r SAML.Issuer - iss = SamlProtocolSettings.spIssuer mbTid mbHostDom >>= maybe err pure + iss = SamlProtocolSettings.spIssuer mbTid mbHost >>= maybe err pure rsp :: Sem r URI.URI - rsp = SamlProtocolSettings.responseURI mbTid mbHostDom >>= maybe err pure + rsp = SamlProtocolSettings.responseURI mbTid mbHost >>= maybe err pure contactList :: Sem r [SAML.ContactPerson] - contactList = SamlProtocolSettings.contactPersons mbHostDom + contactList = SamlProtocolSettings.contactPersons mbHost SAML2.meta appName iss rsp contactList @@ -324,7 +321,7 @@ authreqPrecheck :: Maybe URI.URI -> Maybe CookieLabel -> SAML.IdPId -> - Maybe Text -> + Maybe ZHostValue -> Sem r NoContent authreqPrecheck samlConfig msucc merr mlabel idpid mbHost = validateAuthreqParams msucc merr mlabel *> do @@ -352,7 +349,7 @@ authreq :: Maybe URI.URI -> Maybe CookieLabel -> SAML.IdPId -> - Maybe Text -> + Maybe ZHostValue -> Sem r (SAML.FormRedirect SAML.AuthnRequest) authreq samlConfig authreqttl msucc merr mlabel idpid mbHost = do vformat <- validateAuthreqParams msucc merr mlabel @@ -363,14 +360,13 @@ authreq samlConfig authreqttl msucc merr mlabel idpid mbHost = do let err :: Sem r any err = throwSparSem (SparSPNotFound "") - mbHostDom <- (\host -> mkDomain host & either (const err) pure) `mapM` mbHost let mbtid :: Maybe TeamId mbtid = case fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . apiVersion) of WireIdPAPIV1 -> Nothing WireIdPAPIV2 -> Just $ idp ^. SAML.idpExtraInfo . team iss :: Sem r SAML.Issuer - iss = SamlProtocolSettings.spIssuer mbtid mbHostDom >>= maybe err pure + iss = SamlProtocolSettings.spIssuer mbtid mbHost >>= maybe err pure SAML2.authReq authreqttl iss idpid VerdictFormatStore.store authreqttl reqid vformat pure form @@ -380,7 +376,7 @@ checkMultiIngressDomain :: Member (Error SparError) r ) => SAML.Config -> - Maybe Text -> + Maybe ZHostValue -> IdP -> Sem r () checkMultiIngressDomain samlConfig mbHost idp = when (SAML.isMultiIngressConfig samlConfig) $ do @@ -393,8 +389,8 @@ checkMultiIngressDomain samlConfig mbHost idp = when (SAML.isMultiIngressConfig Logger.debug $ Log.msg ("Multi-ingress domain guard rejected IdP access" :: ByteString) . Log.field "idp" idpIdTxt - . Log.field "idp_domain" (fromMaybe "none" idpDomain) - . Log.field "request_host" (fromMaybe "none" mbHost) + . Log.field "idp_domain" (maybe "None" domainText idpDomain) + . Log.field "request_host" (maybe "None" domainText mbHost) throwSparSem (SparIdPNotFound idpIdTxt) idpIdToText :: SAML.IdPId -> T.Text @@ -430,6 +426,7 @@ authresp :: Member VerdictFormatStore r, Member AReqIDStore r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member SAML2 r, Member SamlProtocolSettings r, @@ -439,30 +436,28 @@ authresp :: ) => Maybe TeamId -> SAML.AuthnResponseBody -> - Maybe Text -> + Maybe ZHostValue -> Sem r Void authresp mbtid arbody mbHost = do let err :: Sem r any err = throwSparSem (SparSPNotFound "") - mbHostDom <- (\host -> mkDomain host & either (const err) pure) `mapM` mbHost - let iss :: Sem r SAML.Issuer - iss = SamlProtocolSettings.spIssuer mbtid mbHostDom >>= maybe err pure + iss = SamlProtocolSettings.spIssuer mbtid mbHost >>= maybe err pure rsp :: Sem r URI.URI - rsp = SamlProtocolSettings.responseURI mbtid mbHostDom >>= maybe err pure + rsp = SamlProtocolSettings.responseURI mbtid mbHost >>= maybe err pure logErrors $ SAML2.authResp mbtid iss rsp go arbody where - go :: NonEmpty SAML.Assertion -> IdP -> SAML.AccessVerdict -> Sem r Void + go :: NE.NonEmpty SAML.Assertion -> IdP -> SAML.AccessVerdict -> Sem r Void go assertions idp verdict = do assertCertsAllowlisted (idp ^. SAML.idpMetadata) case verdict of SAML.AccessDenied (shouldRedirectToInit -> True) -> redirectToInit idp _ -> do - SAML.ResponseVerdict result <- verdictHandler assertions verdict idp + SAML.ResponseVerdict result <- verdictHandler assertions verdict idp mbHost throw @SparError $ SAML.CustomServant result -- Whenever at least one of the denied reasons is `DeniedNoInResponseTo`, try again. @@ -766,7 +761,7 @@ logIdPAction msg idp zUser additionalFields = . Log.field "team" (idp ^. SAML.idpExtraInfo . team . to idToText) . Log.field "idpId" (idp ^. SAML.idpId . to SAML.fromIdPId . to UUID.toString) . Log.field "issuer" (idp ^. SAML.idpMetadata . SAML.edIssuer . SAML.fromIssuer . to URI.serializeURIRef') - . Log.field "domain" (idp ^. SAML.idpExtraInfo . domain . to (fromMaybe "None")) + . Log.field "domain" (idp ^. SAML.idpExtraInfo . domain . to (maybe "None" domainText)) . Log.field "user" (maybe "None" idToText zUser) . Log.field "certificates" (idp ^. SAML.idpMetadata . SAML.edCertAuthnResponse . to (intercalate ";; " . map certToString . toList)) . Log.field "idp-endpoint" (idp ^. SAML.idpMetadata . SAML.edRequestURI . to URI.serializeURIRef') @@ -774,7 +769,7 @@ logIdPAction msg idp zUser additionalFields = -- | Only return a ZHost when multi-ingress is configured and the host value is a configured domain filterMultiIngressZHost :: Either SAML.MultiIngressDomainConfig (Map Domain SAML.MultiIngressDomainConfig) -> Maybe ZHostValue -> Maybe ZHostValue -filterMultiIngressZHost (Right domainMap) (Just zHost) | (Domain zHost) `Map.member` domainMap = Just zHost +filterMultiIngressZHost (Right domainMap) (Just zHost) | zHost `Map.member` domainMap = Just zHost filterMultiIngressZHost _ _ = Nothing idpCreateV7 :: @@ -816,38 +811,6 @@ idpCreateV7 samlConfig tid zUser idpmeta mReplaces mApiversion mHandle = do throwSparSem $ SparProvisioningMoreThanOneIdP ScimTokenAndSecondIdpForbidden --- | Reject IdPs whose cert SHA-1 is not in the configured allowlist. --- --- Empty/absent allowlist is a no-op. On miss: warn-log fingerprint + --- issuer, then throw 'SparIdPCertNotAllowed' (HTTP 403). -assertCertsAllowlisted :: - ( Member (Input Opts) r, - Member (Logger (Msg -> Msg)) r, - Member (Error SparError) r - ) => - SAML.IdPMetadata -> - Sem r () -assertCertsAllowlisted idpmeta = do - mAllow <- inputs idpCertFingerprintAllowlist - case mAllow of - Nothing -> pure () - Just (CertFingerprintAllowlist allowed) - | Set.null allowed -> pure () - | otherwise -> do - let certs = idpmeta ^. SAML.edCertAuthnResponse - issuerTxt = - TE.decodeUtf8 $ - URI.serializeURIRef' (idpmeta ^. SAML.edIssuer . SAML.fromIssuer) - forM_ certs $ \c -> do - let fingerprint = certSha1Fingerprint c - unless (Set.member fingerprint allowed) $ do - let fingerprintHex = renderFingerprintHex fingerprint - Logger.warn $ - Log.msg ("Refusing IdP request: cert fingerprint not in allowlist" :: ByteString) - . Log.field "fingerprint" fingerprintHex - . Log.field "issuer" issuerTxt - throwSparSem (SparIdPCertNotAllowed (T.fromStrict fingerprintHex)) - -- | Check that issuer is not used anywhere in the system ('WireIdPAPIV1', here it is a -- database key for finding IdPs), or anywhere in this team ('WireIdPAPIV2'), that request -- URI is https, that the replacement IdPId, if present, points to our team, and possibly @@ -1017,7 +980,7 @@ idpUpdateXML samlConfig mbZUsr mDomain raw idpmeta idpid mHandle = withDebugLog (idp ^. SAML.idpMetadata . SAML.edIssuer . SAML.fromIssuer) . logChangeableScalar "domain" - (fromMaybe "None") + (maybe "None" domainText) (previousIdP ^. SAML.idpExtraInfo . domain) (idp ^. SAML.idpExtraInfo . domain) . Log.field "user" (idToText zUsr) @@ -1044,7 +1007,7 @@ idpUpdateXML samlConfig mbZUsr mDomain raw idpmeta idpid mHandle = withDebugLog Log.field fieldName ((intercalate ";; " . map certToString) certs) logCertField _ _ = id - compareNonEmpty :: (Eq a) => NonEmpty a -> NonEmpty a -> ([a], [a]) + compareNonEmpty :: (Eq a) => NE.NonEmpty a -> NE.NonEmpty a -> ([a], [a]) compareNonEmpty xs ys = let l = nub . toList $ xs r = nub . toList $ ys @@ -1187,7 +1150,7 @@ internalPutSsoSettings SsoSettings {defaultSsoCode = Just code} = *> DefaultSsoCode.store code $> NoContent -internalGetScimUserInfo :: (Member ScimUserTimesStore r) => UserId -> Sem r ScimUserInfo +internalGetScimUserInfo :: (Member ScimUserMetaStore r) => UserId -> Sem r ScimUserInfo internalGetScimUserInfo uid = do - t <- fmap fst <$> ScimUserTimesStore.read uid + t <- fmap (.scimUserMetaCreated) <$> ScimUserMetaStore.read uid pure $ ScimUserInfo uid t diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index 8ec7c658523..a5e959ea23a 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -30,6 +30,7 @@ module Spar.App validateEmail, errorPage, deleteTeam, + assertCertsAllowlisted, sparToServerErrorWithLogging, renderSparErrorWithLogging, ) @@ -39,26 +40,33 @@ import Bilge import qualified Cassandra as Cas import Control.Exception (assert) import Control.Lens hiding ((.=)) +import Control.Monad.Trans.Maybe (MaybeT (..), runMaybeT) import Data.Aeson as Aeson (encode, object, (.=)) import Data.Aeson.Text as Aeson (encodeToLazyText) import Data.ByteString (toStrict) import qualified Data.ByteString.Builder as Builder import qualified Data.ByteString.UTF8 as UTF8 import qualified Data.CaseInsensitive as CI +import Data.Domain import Data.Id import Data.List.NonEmpty (NonEmpty ((:|))) import qualified Data.List.NonEmpty as NonEmpty +import qualified Data.Set as Set import qualified Data.Text as Text import Data.Text.Ascii (encodeBase64, toText) +import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as Text import qualified Data.Text.Lazy as LText import qualified Data.Text.Lazy.Encoding as LText import Data.These +import qualified Data.X509 as X509 +import Data.X509.Extended import Imports hiding (MonadReader, asks, log) import qualified Network.HTTP.Types.Status as Http import qualified Network.Wai.Utilities.Error as Wai import Polysemy import Polysemy.Error +import Polysemy.Input import SAML2.Util (renderURI) import SAML2.WebSSO ( Issuer (..), @@ -75,16 +83,12 @@ import qualified Spar.Intra.RpcApp as Intra import Spar.Options import Spar.Orphans () import Spar.Sem.AReqIDStore (AReqIDStore) -import Spar.Sem.Reporter (Reporter) -import qualified Spar.Sem.Reporter as Reporter import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import Spar.Sem.ScimTokenStore (ScimTokenStore) import qualified Spar.Sem.ScimTokenStore as ScimTokenStore -import Spar.Sem.VerdictFormatStore (VerdictFormatStore) -import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore +import System.Logger (Msg) +import qualified System.Logger as Log import qualified System.Logger as TinyLog import URI.ByteString as URI import Web.Cookie (SetCookie, renderSetCookie) @@ -100,11 +104,17 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess as GalleyAPIAccess import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore +import Wire.Reporter (Reporter) +import qualified Wire.Reporter as Reporter +import Wire.ScimExternalIdStore (ScimExternalIdStore) +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore import Wire.ScimSubsystem.Interpreter import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger import Wire.Sem.Random (Random) import qualified Wire.Sem.Random as Random +import Wire.VerdictFormatStore (VerdictFormatStore) +import qualified Wire.VerdictFormatStore as VerdictFormatStore throwSparSem :: (Member (Error SparError) r) => SparCustomError -> Sem r a throwSparSem = throw . SAML.CustomError @@ -276,26 +286,29 @@ validateEmail _ _ _ = pure () verdictHandler :: (HasCallStack) => ( Member Random r, - Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member GalleyAPIAccess r, Member BrigAPIAccess r, Member AReqIDStore r, Member VerdictFormatStore r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member (Error SparError) r, Member Reporter r, - Member SAMLUserStore r + Member SAMLUserStore r, + Member (Input Opts) r ) => NonEmpty SAML.Assertion -> SAML.AccessVerdict -> IdP -> + Maybe Domain -> Sem r SAML.ResponseVerdict -verdictHandler aresp verdict idp = do +verdictHandler aresp verdict idp mbHost = do -- [3/4.1.4.2] -- [...] If the containing message is in response to an , then -- the InResponseTo attribute MUST match the request's ID. - Logger.log Logger.Debug $ "entering verdictHandler: " <> show (aresp, verdict) + Logger.debug $ Log.msg ("entering verdictHandler" :: String) . Log.field "aresp" (show aresp) . Log.field "verdict" (show verdict) reqid <- do let xs = SAML.assertionToInResponseTo `mapM` aresp case NonEmpty.nub <$> xs of @@ -305,13 +318,13 @@ verdictHandler aresp verdict idp = do format :: Maybe VerdictFormat <- VerdictFormatStore.get reqid resp <- case format of Just (VerdictFormatWeb mlabel) -> - verdictHandlerResult verdict idp mlabel >>= verdictHandlerWeb + verdictHandlerResult verdict idp mlabel mbHost >>= verdictHandlerWeb Just (VerdictFormatMobile granted denied mlabel) -> - verdictHandlerResult verdict idp mlabel >>= verdictHandlerMobile granted denied + verdictHandlerResult verdict idp mlabel mbHost >>= verdictHandlerMobile granted denied Nothing -> -- (this shouldn't happen too often, see 'storeVerdictFormat') throwSparSem SparNoSuchRequest - Logger.log Logger.Debug $ "leaving verdictHandler: " <> show resp + Logger.debug $ Log.msg ("leaving verdictHandler" :: String) . Log.field "resp" (show resp) pure resp data VerdictHandlerResult @@ -323,23 +336,26 @@ data VerdictHandlerResult verdictHandlerResult :: (HasCallStack) => ( Member Random r, - Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member GalleyAPIAccess r, Member BrigAPIAccess r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member (Error SparError) r, Member Reporter r, - Member SAMLUserStore r + Member SAMLUserStore r, + Member (Input Opts) r ) => SAML.AccessVerdict -> IdP -> Maybe CookieLabel -> + Maybe Domain -> Sem r VerdictHandlerResult -verdictHandlerResult verdict idp mlabel = do - Logger.log Logger.Debug $ "entering verdictHandlerResult" - result <- catchVerdictErrors $ verdictHandlerResultCore idp verdict mlabel - Logger.log Logger.Debug $ "leaving verdictHandlerResult" <> show result +verdictHandlerResult verdict idp mlabel mbHost = do + Logger.debug $ Log.msg ("entering verdictHandlerResult" :: String) + result <- catchVerdictErrors $ verdictHandlerResultCore idp verdict mlabel mbHost + Logger.debug $ Log.msg ("leaving verdictHandlerResult" :: String) . Log.field "result" (show result) pure result catchVerdictErrors :: @@ -399,48 +415,154 @@ moveUserToNewIssuer oldUserRef newUserRef uid = do SAMLUserStore.delete uid oldUserRef verdictHandlerResultCore :: + forall r. (HasCallStack) => ( Member Random r, - Member (Logger String) r, + Member (Logger (Msg -> Msg)) r, Member GalleyAPIAccess r, Member BrigAPIAccess r, Member ScimTokenStore r, + Member ScimExternalIdStore r, Member IdPConfigStore r, Member (Error SparError) r, - Member SAMLUserStore r + Member SAMLUserStore r, + Member (Input Opts) r ) => IdP -> SAML.AccessVerdict -> Maybe CookieLabel -> + Maybe Domain -> Sem r VerdictHandlerResult -verdictHandlerResultCore idp verdict mlabel = case verdict of +verdictHandlerResultCore idp verdict mlabel mbHost = case verdict of SAML.AccessDenied reasons -> do pure $ VerifyHandlerDenied reasons SAML.AccessGranted uref -> do uid :: UserId <- do let team' = idp ^. idpExtraInfo . team - err = SparUserRefInNoOrMultipleTeams . LText.pack . show $ uref - getUserByUrefUnsafe uref >>= \case - Just usr -> do - if userTeam usr == Just team' - then pure (userId usr) - else throwSparSem err - Nothing -> do - getUserByUrefViaOldIssuerUnsafe idp uref >>= \case - Just (olduref, usr) -> do - let uid = userId usr - if userTeam usr == Just team' - then moveUserToNewIssuer olduref uref uid >> pure uid - else throwSparSem err - Nothing -> do - buid <- Id <$> Random.uuid - autoprovisionSamlUser idp buid uref - validateSamlEmailIfExists buid uref - pure buid - - Logger.log Logger.Debug ("granting sso login for " <> show uid) + samlConfig <- input <&> (.saml) + findUserWithUref idp team' uref >>= \case + Just uid -> pure uid + Nothing -> + if SAML.isMultiIngressConfig samlConfig + then multiIngressFlow team' + else provisionNewUser + Logger.debug $ Log.msg ("granting sso login" :: String) . Log.field "user" (idToText uid) cky <- BrigAPIAccess.ssoLogin uid mlabel pure $ VerifyHandlerGranted cky uid + where + provisionNewUser :: Sem r UserId + provisionNewUser = do + buid <- Id <$> Random.uuid + autoprovisionSamlUser idp buid uref + validateSamlEmailIfExists buid uref + pure buid + + -- Try to find a user by UserRef, with fallback to old issuers. Returns + -- the UserId if found and in the correct team, Nothing if not found. + -- Throws SparUserRefInNoOrMultipleTeams if user is found but in the + -- wrong team. Side effect: Old-style users (found via old issuers) are + -- migrated to the new issuer. + findUserWithUref :: IdP -> TeamId -> SAML.UserRef -> Sem r (Maybe UserId) + findUserWithUref idp' team'' uref' = do + let err = SparUserRefInNoOrMultipleTeams . LText.pack . show $ uref' + getUserByUrefUnsafe uref' >>= \case + Just usr -> do + if userTeam usr == Just team'' + then pure (Just (userId usr)) + else throwSparSem err + Nothing -> do + getUserByUrefViaOldIssuerUnsafe idp' uref' >>= \case + Just (olduref, usr) -> do + let uid = userId usr + if userTeam usr == Just team'' + then moveUserToNewIssuer olduref uref' uid >> pure (Just uid) + else throwSparSem err + Nothing -> pure Nothing + + -- In multi-ingress scenarios users can be already assigned to one IdP, + -- but try to authenticate with another. This happens when users switch + -- the used domain as IdPs are domain-bound. We allow this, when the new + -- IdP is configured for the user's team and the used domain. + -- Additionally, the provided NameId must be an email address (no + -- username) to prevent ambiguities (though, we know this won't be + -- guarding against all ambiguity cases). + -- When we've found the matching IdP and the user's old one, we migrate + -- the user to the new one to not have to run this search again when the + -- user logs in with this IdP. + multiIngressFlow :: TeamId -> Sem r UserId + multiIngressFlow team' = + case uref of + SAML.UserRef _ (view SAML.nameID -> UNameIDEmail _) -> do + teamIdPs <- IdPConfigStore.getConfigsByTeam team' + let urefIssuer = uref ^. SAML.uidTenant + + case selectAuthenticatingIdP teamIdPs urefIssuer mbHost of + Nothing -> do + let issuerText = urefIssuer ^. SAML.fromIssuer . to (TE.decodeUtf8 . URI.serializeURIRef') + domainAsText = maybe "default" domainText mbHost + errorMsg = + "IdP with issuer '" + <> issuerText + <> "' for domain '" + <> domainAsText + <> "' is not configured for this team" + throwSparSem $ SparIdPNotFound (LText.fromStrict errorMsg) + Just multiIngressIdp -> do + assertCertsAllowlisted (multiIngressIdp ^. SAML.idpMetadata) + let subject = uref ^. SAML.uidSubject + findUserInTeamIdPs team' subject teamIdPs >>= \case + Nothing -> do + logMultiIngressProvisioningNewUser idp uref multiIngressIdp mbHost + provisionNewUser + Just (uid, oldUref) -> do + logMultiIngressMigratingUser idp uid oldUref uref multiIngressIdp mbHost + moveUserToNewIssuer oldUref uref uid + pure uid + _userRef -> + throwSparSem . SparMultiIngressIdPConfiguration $ + "Multi-ingress SSO only supports email-based NameIDs for cross-IdP migration. " + <> "Username-based NameIDs are not allowed." + + -- Try to authenticate against all IdPs. In case, return the UserId and the old UserRef. + findUserInTeamIdPs :: TeamId -> SAML.NameID -> [IdP] -> Sem r (Maybe (UserId, SAML.UserRef)) + findUserInTeamIdPs team'' subject idps = runMaybeT $ asum $ map tryIdP idps + where + tryIdP :: IdP -> MaybeT (Sem r) (UserId, SAML.UserRef) + tryIdP idp' = do + let oldIssuer = idp' ^. SAML.idpMetadata . SAML.edIssuer + oldUref = SAML.UserRef oldIssuer subject + uid <- MaybeT $ findUserWithUref idp' team'' oldUref + pure (uid, oldUref) + + selectAuthenticatingIdP :: [IdP] -> Issuer -> Maybe Domain -> Maybe IdP + selectAuthenticatingIdP teamIdPs issuer mbDomain = + find matchesIssuerAndDomain teamIdPs + where + matchesIssuerAndDomain idp' = + idp' ^. SAML.idpMetadata . SAML.edIssuer == issuer + && idp' ^. idpExtraInfo . domain == mbDomain + + logMultiIngressProvisioningNewUser :: IdP -> SAML.UserRef -> IdP -> Maybe Domain -> Sem r () + logMultiIngressProvisioningNewUser idp' uref' multiIngressIdp' mbHost' = + Logger.info $ + Log.msg ("Multi-ingress SSO: IdP found but user does not exist, provisioning new user" :: String) + . Log.field "team" (idToText (idp' ^. idpExtraInfo . team)) + . Log.field "issuer" (uref' ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef') + . Log.field "multi_ingress_idp" (multiIngressIdp' ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "authenticating_idp" (idp' ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "domain" (mbHost' & maybe "None" domainText) + + logMultiIngressMigratingUser :: IdP -> UserId -> SAML.UserRef -> SAML.UserRef -> IdP -> Maybe Domain -> Sem r () + logMultiIngressMigratingUser idp' uid' oldUref' uref' multiIngressIdp' mbHost' = + Logger.info $ + Log.msg ("Multi-ingress SSO: user found via different IdP, migrating issuer" :: String) + . Log.field "team" (idToText (idp' ^. idpExtraInfo . team)) + . Log.field "user" (idToText uid') + . Log.field "old_issuer" (oldUref' ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef') + . Log.field "new_issuer" (uref' ^. SAML.uidTenant . SAML.fromIssuer . to URI.serializeURIRef') + . Log.field "authenticating_idp" (idp' ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "multi_ingress_idp" (multiIngressIdp' ^. SAML.idpId . to SAML.fromIdPId . to show) + . Log.field "domain" (mbHost' & maybe "None" domainText) -- | If the client is web, it will be served with an HTML page that it can process to decide whether -- to log the user in or show an error. @@ -614,6 +736,68 @@ deleteTeam team' = do SAMLUserStore.deleteByIssuer issuer IdPConfigStore.deleteConfig idp +-- | Reject IdPs whose cert SHA-1 is not in the configured allowlist. +-- +-- Empty/absent allowlist is a no-op in the regular case, it short-circuits to +-- error for multi-ingress setups. I.e. the allowlist is required for +-- multi-ingress setups. +assertCertsAllowlisted :: + forall r. + ( Member (Input Opts) r, + Member (Logger (Msg -> Msg)) r, + Member (Error SparError) r + ) => + SAML.IdPMetadata -> + Sem r () +assertCertsAllowlisted idpmeta = do + mAllow <- inputs idpCertFingerprintAllowlist + let certs = idpmeta ^. SAML.edCertAuthnResponse + issuerTxt = + TE.decodeUtf8 $ + URI.serializeURIRef' (idpmeta ^. SAML.edIssuer . SAML.fromIssuer) + guardMultiIngressCertsAllowlistNotEmpty mAllow certs issuerTxt + case mAllow of + Nothing -> pure () + Just (CertFingerprintAllowlist allowed) + | Set.null allowed -> pure () + | otherwise -> do + forM_ certs $ \c -> do + let fingerprint = certSha1Fingerprint c + fingerprintHex = renderFingerprintHex fingerprint + unless (Set.member fingerprint allowed) $ do + logCertNotInAllowlist fingerprintHex issuerTxt + throwSparSem (SparIdPCertNotAllowed (LText.fromStrict fingerprintHex)) + where + logCertNotInAllowlist fingerprintHex issuerTxt = + Logger.warn $ + Log.msg ("Refusing IdP request: cert fingerprint not in allowlist" :: ByteString) + . Log.field "fingerprint" fingerprintHex + . Log.field "issuer" issuerTxt + + guardMultiIngressCertsAllowlistNotEmpty :: + Maybe CertFingerprintAllowlist -> + NonEmpty X509.SignedCertificate -> + Text -> + Sem r () + guardMultiIngressCertsAllowlistNotEmpty mAllow certs issuerTxt = do + samlConfig <- inputs saml + + when (isEmptyAllowList mAllow && SAML.isMultiIngressConfig samlConfig) $ do + let fingerprintHex = renderFingerprintHex . certSha1Fingerprint . NonEmpty.head $ certs + logMultiIngressEmptyAllowlist fingerprintHex + throwSparSem (SparIdPCertNotAllowed (LText.fromStrict fingerprintHex)) + where + logMultiIngressEmptyAllowlist fingerprintHex = + Logger.warn $ + Log.msg ("Refusing IdP request: multi-ingress enabled and allowlist empty" :: ByteString) + . Log.field "fingerprint" fingerprintHex + . Log.field "issuer" issuerTxt + + isEmptyAllowList :: Maybe CertFingerprintAllowlist -> Bool + isEmptyAllowList Nothing = True + isEmptyAllowList (Just (CertFingerprintAllowlist allowed)) | Set.null allowed = True + isEmptyAllowList (Just _) = False + sparToServerErrorWithLogging :: (Member Reporter r) => SparError -> Sem r ServerError sparToServerErrorWithLogging = fmap httpErrorToServerError . renderSparErrorWithLogging diff --git a/services/spar/src/Spar/CanonicalInterpreter.hs b/services/spar/src/Spar/CanonicalInterpreter.hs index 235a7f78392..72155ab880f 100644 --- a/services/spar/src/Spar/CanonicalInterpreter.hs +++ b/services/spar/src/Spar/CanonicalInterpreter.hs @@ -41,49 +41,49 @@ import Spar.Sem.AReqIDStore (AReqIDStore) import Spar.Sem.AReqIDStore.Cassandra (aReqIDStoreToCassandra) import Spar.Sem.AssIDStore (AssIDStore) import Spar.Sem.AssIDStore.Cassandra (assIDStoreToCassandra) -import Spar.Sem.DefaultSsoCode (DefaultSsoCode) -import Spar.Sem.DefaultSsoCode.Cassandra (defaultSsoCodeToCassandra) -import Spar.Sem.IdPRawMetadataStore (IdPRawMetadataStore) -import Spar.Sem.IdPRawMetadataStore.Cassandra (idpRawMetadataStoreToCassandra) -import Spar.Sem.Reporter (Reporter) -import Spar.Sem.Reporter.Wai (reporterToTinyLogWai) import Spar.Sem.SAML2 (SAML2) import Spar.Sem.SAML2.Library (saml2ToSaml2WebSso) import Spar.Sem.SAMLUserStore (SAMLUserStore) import Spar.Sem.SAMLUserStore.Cassandra (samlUserStoreToCassandra) -import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) -import Spar.Sem.SamlProtocolSettings.Servant (sparRouteToServant) -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) -import Spar.Sem.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Spar.Sem.ScimTokenStore (ScimTokenStore) import Spar.Sem.ScimTokenStore.Cassandra (scimTokenStoreToCassandra) -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) -import Spar.Sem.ScimUserTimesStore.Cassandra (scimUserTimesStoreToCassandra) import Spar.Sem.Utils -import Spar.Sem.VerdictFormatStore (VerdictFormatStore) -import Spar.Sem.VerdictFormatStore.Cassandra (verdictFormatStoreToCassandra) import qualified System.Logger as TinyLog import Wire.API.Routes.Version (expandVersionExp) import Wire.API.User.Saml (TTLError) import Wire.BrigAPIAccess import Wire.BrigAPIAccess.Rpc import Wire.ClientSubsystem.Error (ClientError, clientErrorToHttpError) +import Wire.DefaultSsoStore (DefaultSsoCode) +import Wire.DefaultSsoStore.Cassandra (defaultSsoCodeToCassandra) import Wire.GalleyAPIAccess import Wire.GalleyAPIAccess.Rpc import Wire.IdPConfigStore (IdPConfigStore) import Wire.IdPConfigStore.Cassandra (idPToCassandra) +import Wire.IdPRawMetadataStore (IdPRawMetadataStore) +import Wire.IdPRawMetadataStore.Cassandra (idpRawMetadataStoreToCassandra) import Wire.IdPSubsystem (IdPSubsystem) import Wire.IdPSubsystem.Interpreter (IdPSubsystemError, interpretIdPSubsystem) import Wire.ParseException (ParseException, parseExceptionToHttpError) +import Wire.Reporter (Reporter) +import Wire.Reporter.Wai (reporterToTinyLogWai) import Wire.Rpc (Rpc, runRpcWithHttp) import Wire.RpcException +import Wire.SamlProtocolSettings (SamlProtocolSettings) +import Wire.SamlProtocolSettings.Servant (sparRouteToServant) +import Wire.ScimExternalIdStore (ScimExternalIdStore) +import Wire.ScimExternalIdStore.Cassandra (scimExternalIdStoreToCassandra) import Wire.ScimSubsystem import Wire.ScimSubsystem.Interpreter +import Wire.ScimUserMetaStore (ScimUserMetaStore) +import Wire.ScimUserMetaStore.Cassandra (scimUserMetaStoreToCassandra) import Wire.Sem.Logger.TinyLog (loggerToTinyLog, stringLoggerToTinyLog) import Wire.Sem.Now (Now) import Wire.Sem.Now.IO (nowToIO) import Wire.Sem.Random (Random) import Wire.Sem.Random.IO (randomToIO) +import Wire.VerdictFormatStore (VerdictFormatStore) +import Wire.VerdictFormatStore.Cassandra (verdictFormatStoreToCassandra) type CanonicalEffs = '[IdPSubsystem, ScimSubsystem] @@ -104,7 +104,7 @@ type LowerLevelCanonicalEffs = Error IdPSubsystemError, Error ScimSubsystemError, ScimExternalIdStore, - ScimUserTimesStore, + ScimUserMetaStore, ScimTokenStore, DefaultSsoCode, IdPConfigStore, @@ -147,7 +147,7 @@ runSparToIO ctx = . idPToCassandra . defaultSsoCodeToCassandra . scimTokenStoreToCassandra - . scimUserTimesStoreToCassandra + . scimUserMetaStoreToCassandra . scimExternalIdStoreToCassandra . mapScimSubsystemErrors . mapIdPSubsystemErrors diff --git a/services/spar/src/Spar/Data.hs b/services/spar/src/Spar/Data.hs index 3daa686b6d4..2f6869f2231 100644 --- a/services/spar/src/Spar/Data.hs +++ b/services/spar/src/Spar/Data.hs @@ -21,7 +21,6 @@ module Spar.Data Env (..), mkEnv, mkTTLAssertions, - nominalDiffToSeconds, mkTTLAuthnRequests, -- * SAML Users diff --git a/services/spar/src/Spar/Data/Instances.hs b/services/spar/src/Spar/Data/Instances.hs index 1bd89c0c377..b8d13222bdf 100644 --- a/services/spar/src/Spar/Data/Instances.hs +++ b/services/spar/src/Spar/Data/Instances.hs @@ -20,16 +20,7 @@ -- | 'Cql' instances for Spar types, as well as conversion functions used in "Spar.Data" -- (which does the actual database work). -module Spar.Data.Instances - ( -- * Raw database types - VerdictFormatRow, - VerdictFormatCon (..), - - -- ** Conversions - fromVerdictFormat, - toVerdictFormat, - ) -where +module Spar.Data.Instances () where import Cassandra as Cas import Data.ByteString (toStrict) @@ -38,37 +29,8 @@ import Data.Functor.Alt (Alt (())) import qualified Data.Text.Encoding as T import Data.Text.Encoding.Error import Imports -import Spar.Scim.Types (ScimUserCreationStatus (..)) -import URI.ByteString -import Wire.API.User.Auth -import Wire.API.User.Saml import Wire.API.User.Scim -type VerdictFormatRow = (VerdictFormatCon, Maybe URI, Maybe URI, Maybe CookieLabel) - -data VerdictFormatCon = VerdictFormatConWeb | VerdictFormatConMobile - -instance Cql VerdictFormatCon where - ctype = Tagged IntColumn - - toCql VerdictFormatConWeb = CqlInt 0 - toCql VerdictFormatConMobile = CqlInt 1 - - fromCql (CqlInt i) = case i of - 0 -> pure VerdictFormatConWeb - 1 -> pure VerdictFormatConMobile - n -> Left $ "unexpected VerdictFormatCon: " ++ show n - fromCql _ = Left "member-status: int expected" - -fromVerdictFormat :: VerdictFormat -> VerdictFormatRow -fromVerdictFormat (VerdictFormatWeb mlabel) = (VerdictFormatConWeb, Nothing, Nothing, mlabel) -fromVerdictFormat (VerdictFormatMobile succredir errredir mlabel) = (VerdictFormatConMobile, Just succredir, Just errredir, mlabel) - -toVerdictFormat :: VerdictFormatRow -> Maybe VerdictFormat -toVerdictFormat (VerdictFormatConWeb, Nothing, Nothing, mlabel) = Just $ VerdictFormatWeb mlabel -toVerdictFormat (VerdictFormatConMobile, Just succredir, Just errredir, mlabel) = Just $ VerdictFormatMobile succredir errredir mlabel -toVerdictFormat _ = Nothing - deriving instance Cql ScimToken instance Cql ScimTokenHash where @@ -90,15 +52,3 @@ instance Cql ScimTokenLookupKey where (ScimTokenLookupKeyHashed <$> fromCql s) (ScimTokenLookupKeyPlaintext <$> fromCql s) fromCql _ = Left "ScimTokenLookupKey: expected CqlText" - -instance Cql ScimUserCreationStatus where - ctype = Tagged IntColumn - - toCql ScimUserCreated = CqlInt 0 - toCql ScimUserCreating = CqlInt 1 - - fromCql (CqlInt i) = case i of - 0 -> pure ScimUserCreated - 1 -> pure ScimUserCreating - n -> Left $ "unexpected ScimUserCreationStatus: " ++ show n - fromCql _ = Left "int expected" diff --git a/services/spar/src/Spar/Error.hs b/services/spar/src/Spar/Error.hs index c2b8c97de65..a2cd61d9f73 100644 --- a/services/spar/src/Spar/Error.hs +++ b/services/spar/src/Spar/Error.hs @@ -118,6 +118,7 @@ data SparCustomError SparScimError Scim.ScimError | SparIdPDomainInUse | SparIdPCertNotAllowed LText + | SparMultiIngressIdPConfiguration LText deriving (Eq, Show) data SparProvisioningMoreThanOneIdP @@ -134,7 +135,13 @@ sparToServerErrorWithLogging logger err = do pure errServant sparToServerError :: SparError -> ServerError -sparToServerError = httpErrorToServerError . renderSparError +-- SCIM errors have their own response format (RFC 7644, section 3.12): the body +-- must be the bare SCIM error object. Going through 'renderSparError' / +-- 'httpErrorToServerError' would instead nest it into a wire-server 'Wai.Error' +-- ('{"code":..,"label":"scim-error","message":}'), so we +-- render it directly here. +sparToServerError (SAML.CustomError (SparScimError err)) = Scim.scimToServerError err +sparToServerError err = httpErrorToServerError (renderSparError err) waiToServant :: Wai.Error -> ServerError waiToServant waierr = @@ -230,6 +237,7 @@ renderSparError (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) = status403 "idp-cert-not-allowed" ("IdP certificate not in the configured allowlist: " <> fingerprint) +renderSparError (SAML.CustomError (SparMultiIngressIdPConfiguration msg)) = StdError $ Wai.mkError status400 "multi-ingress-config-error" msg -- Errors related to provisioning renderSparError (SAML.CustomError (SparProvisioningMoreThanOneIdP msg)) = StdError $ Wai.mkError status400 "more-than-one-idp" do diff --git a/services/spar/src/Spar/Schema/Run.hs b/services/spar/src/Spar/Schema/Run.hs index 49482353c4b..b1b55bad6af 100644 --- a/services/spar/src/Spar/Schema/Run.hs +++ b/services/spar/src/Spar/Schema/Run.hs @@ -37,6 +37,7 @@ import qualified Spar.Schema.V2 as V2 import qualified Spar.Schema.V20 as V20 import qualified Spar.Schema.V21 as V21 import qualified Spar.Schema.V22 as V22 +import qualified Spar.Schema.V23 as V23 import qualified Spar.Schema.V3 as V3 import qualified Spar.Schema.V4 as V4 import qualified Spar.Schema.V5 as V5 @@ -86,7 +87,8 @@ migrations = V19.migration, V20.migration, V21.migration, - V22.migration + V22.migration, + V23.migration -- TODO: Add a migration that removes unused fields -- (we don't want to risk running a migration which would -- effectively break the currently deployed spar service) diff --git a/services/spar/src/Spar/Schema/V23.hs b/services/spar/src/Spar/Schema/V23.hs new file mode 100644 index 00000000000..a599fa8238f --- /dev/null +++ b/services/spar/src/Spar/Schema/V23.hs @@ -0,0 +1,32 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Spar.Schema.V23 + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = Migration 23 "Store SCIM email metadata (type, primary) in scim_user_times" $ do + schema' + [r| + ALTER TABLE scim_user_times ADD (email_type text, email_primary boolean); + |] diff --git a/services/spar/src/Spar/Scim.hs b/services/spar/src/Spar/Scim.hs index caf48e2e7bd..513b14103e9 100644 --- a/services/spar/src/Spar/Scim.hs +++ b/services/spar/src/Spar/Scim.hs @@ -59,6 +59,7 @@ module Spar.Scim -- * API implementation apiScim, + sparResourceTypes, ) where @@ -83,17 +84,15 @@ import Spar.Options import Spar.Scim.Auth import Spar.Scim.Group () import Spar.Scim.User -import Spar.Sem.Reporter (Reporter) import Spar.Sem.SAMLUserStore (SAMLUserStore) -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) import Spar.Sem.ScimTokenStore (ScimTokenStore) -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) import System.Logger (Msg) import qualified Web.Scim.Capabilities.MetaSchema as Scim.Meta import qualified Web.Scim.Class.Group as Scim.Group import qualified Web.Scim.Class.User as Scim.User import qualified Web.Scim.Handler as Scim import qualified Web.Scim.Schema.Error as Scim +import qualified Web.Scim.Schema.ResourceType as Scim.ResourceType import qualified Web.Scim.Schema.Schema as Scim.Schema import qualified Web.Scim.Server as Scim import Wire.API.Routes.Public.Spar @@ -101,7 +100,10 @@ import Wire.API.User.Scim import Wire.BrigAPIAccess (BrigAPIAccess) import Wire.GalleyAPIAccess (GalleyAPIAccess) import Wire.IdPConfigStore (IdPConfigStore) +import Wire.Reporter (Reporter) +import Wire.ScimExternalIdStore (ScimExternalIdStore) import Wire.ScimSubsystem +import Wire.ScimUserMetaStore (ScimUserMetaStore) import Wire.Sem.Logger (Logger) import Wire.Sem.Now (Now) import Wire.Sem.Random (Random) @@ -125,7 +127,7 @@ apiScim :: Member BrigAPIAccess r, Member ScimSubsystem r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r, + Member ScimUserMetaStore r, Member ScimTokenStore r, Member Reporter r, Member IdPConfigStore r, @@ -190,7 +192,23 @@ server :: ScimSite tag (AsServerT (Scim.ScimHandler m)) server conf = ScimSite - { config = toServant $ Scim.configServer conf, + { config = toServant $ Scim.configServer conf sparResourceTypes, users = \authData -> toServant (Scim.userServer @tag authData), groups = \authData -> toServant (Scim.groupServer @tag authData) } + +-- | The SCIM resource types advertised at @/ResourceTypes@. Unlike the hscim +-- default, the @User@ resource declares the Wire schema extensions that our user +-- responses actually contain (see 'userSchemas'), so that the advertised schemas +-- match the responses as required by RFC 7643 section 6 (issue #5436). +sparResourceTypes :: [Scim.ResourceType.Resource] +sparResourceTypes = + [ Scim.ResourceType.usersResource + { Scim.ResourceType.schemaExtensions = + [ Scim.ResourceType.SchemaExtension sch False + | sch <- userSchemas, + sch /= Scim.Schema.User20 + ] + }, + Scim.ResourceType.groupsResource + ] diff --git a/services/spar/src/Spar/Scim/Types.hs b/services/spar/src/Spar/Scim/Types.hs index b2b6b360af7..abda3fb9a81 100644 --- a/services/spar/src/Spar/Scim/Types.hs +++ b/services/spar/src/Spar/Scim/Types.hs @@ -32,8 +32,6 @@ module Spar.Scim.Types where import Control.Lens (view) import Imports -import Test.QuickCheck (Arbitrary (..)) -import Test.QuickCheck.Gen (elements) import qualified Web.Scim.Schema.Common as Scim import qualified Web.Scim.Schema.User as Scim.User import Wire.API.User (AccountStatus (..)) @@ -89,9 +87,3 @@ normalizeLikeStored usr = tweakActive :: Maybe Scim.ScimBool -> Maybe Scim.ScimBool tweakActive = Just . Scim.ScimBool . maybe True Scim.unScimBool - -data ScimUserCreationStatus = ScimUserCreating | ScimUserCreated - deriving (Eq, Show, Generic) - -instance Arbitrary ScimUserCreationStatus where - arbitrary = elements [ScimUserCreating, ScimUserCreated] diff --git a/services/spar/src/Spar/Scim/User.hs b/services/spar/src/Spar/Scim/User.hs index 118e00d2ed2..95e040bc661 100644 --- a/services/spar/src/Spar/Scim/User.hs +++ b/services/spar/src/Spar/Scim/User.hs @@ -81,10 +81,6 @@ import Spar.Scim.Types import qualified Spar.Scim.Types as ST import Spar.Sem.SAMLUserStore (SAMLUserStore) import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import Spar.Sem.ScimExternalIdStore (ScimExternalIdStore) -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore) -import qualified Spar.Sem.ScimUserTimesStore as ScimUserTimesStore import qualified System.Logger.Class as Log import System.Logger.Message (Msg) import qualified URI.ByteString as URIBS @@ -101,12 +97,13 @@ import qualified Web.Scim.Schema.ResourceType as Scim import qualified Web.Scim.Schema.User as Scim import qualified Web.Scim.Schema.User as Scim.User (schemas) import qualified Web.Scim.Schema.User.Email as Scim.Email +import qualified Web.Scim.Schema.User.Role as Scim.Role import qualified Wire.API.Team.Member as Member import Wire.API.Team.Role import Wire.API.User import Wire.API.User.IdentityProvider (IdP) import qualified Wire.API.User.RichInfo as RI -import Wire.API.User.Scim (ScimTokenInfo (..), ValidScimId (..)) +import Wire.API.User.Scim (ScimTokenInfo (..), ScimUserCreationStatus (..), ValidScimId (..)) import qualified Wire.API.User.Scim as ST import Wire.BrigAPIAccess (BrigAPIAccess) import qualified Wire.BrigAPIAccess as BrigAPIAccess @@ -114,6 +111,10 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess as GalleyAPIAccess import Wire.IdPConfigStore (IdPConfigStore) import qualified Wire.IdPConfigStore as IdPConfigStore +import Wire.ScimExternalIdStore (ScimExternalIdStore) +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore +import Wire.ScimUserMetaStore (ScimUserMetaStore) +import qualified Wire.ScimUserMetaStore as ScimUserMetaStore import Wire.Sem.Logger (Logger) import qualified Wire.Sem.Logger as Logger import Wire.Sem.Now (Now) @@ -133,7 +134,7 @@ instance Member GalleyAPIAccess r, Member BrigAPIAccess r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r, + Member ScimUserMetaStore r, Member IdPConfigStore r, Member SAMLUserStore r ) => @@ -251,6 +252,14 @@ tokenInfoToIdP :: (Member IdPConfigStore r) => ScimTokenInfo -> Scim.ScimHandler tokenInfoToIdP ScimTokenInfo {stiIdP} = mapM (lift . IdPConfigStore.getConfig) stiIdP +-- | Maximum accepted length of the SCIM email @type@ sub-attribute. The value +-- is IdP-controlled and persisted verbatim in @scim_user_times.email_type@, so +-- it must be bounded (mirrors the @richInfoLimit@ idea). NB: validation runs +-- in 'validateScimUser'', through which PATCHes also re-validate the +-- synthesized old user — so a manual backfill must not write a longer value. +maxScimEmailTypeLength :: Int +maxScimEmailTypeLength = 64 + -- | Validate a handle (@userName@). validateHandle :: (Member (Error Scim.ScimError) r) => Text -> Sem r Handle validateHandle txt = case parseHandle txt of @@ -302,7 +311,19 @@ validateScimUser' :: Sem r ST.ValidScimUser validateScimUser' errloc midp richInfoLimit user = do unless (isNothing $ Scim.password user) $ throw $ badRequest "Setting user passwords is not supported for security reasons." - veid <- mkValidScimId midp (Scim.externalId user) (Scim.Email.scimEmailsToEmailAddress $ Scim.emails user) + mSelectedEmail <- + either (throw . badRequest) pure $ + Scim.Email.scimEmailsToEmail (Scim.emails user) + veid <- mkValidScimId midp (Scim.externalId user) (Scim.Email.emailToEmailAddress <$> mSelectedEmail) + let vsuEmailType = mSelectedEmail >>= Scim.Email.typ + vsuEmailPrimary = mSelectedEmail >>= fmap Scim.unScimBool . Scim.Email.primary + for_ vsuEmailType $ \ty -> + when (Text.length ty > maxScimEmailTypeLength) $ + throw $ + badRequest $ + "SCIM email type is too long (max " + <> Text.pack (show maxScimEmailTypeLength) + <> " characters)." handl <- validateHandle . Text.toLower . Scim.userName $ user -- FUTUREWORK: 'Scim.userName' should be case insensitive; then the toLower here would -- be a little less brittle. @@ -321,7 +342,7 @@ validateScimUser' errloc midp richInfoLimit user = do lang <- maybe (throw $ badRequest "Could not parse language. Expected format is ISO 639-1.") pure $ mapM parseLanguage $ Scim.preferredLanguage user mRole <- validateRole user - pure $ ST.ValidScimUser veid handl uname (maybeToList (justHere veid.validScimIdAuthInfo)) richInfo (maybe True Scim.unScimBool active) (flip Locale Nothing <$> lang) mRole + pure $ ST.ValidScimUser veid handl uname (justHere veid.validScimIdAuthInfo) vsuEmailType vsuEmailPrimary richInfo (maybe True Scim.unScimBool active) (flip Locale Nothing <$> lang) mRole where validRoleNames :: Text validRoleNames = @@ -334,11 +355,13 @@ validateScimUser' errloc midp richInfoLimit user = do validateRole = Scim.roles <&> \case [] -> pure Nothing - [role] -> - maybe - (throw $ badRequest $ "The role '" <> role <> "' is not valid. Valid roles are " <> validRoleNames <> ".") - (pure . Just) - (fromByteString $ Text.encodeUtf8 role) + [role] -> case Scim.Role.value role of + Nothing -> throw $ badRequest "A role must have a value." + Just roleNm -> + maybe + (throw $ badRequest $ "The role '" <> roleNm <> "' is not valid. Valid roles are " <> validRoleNames <> ".") + (pure . Just) + (fromByteString $ Text.encodeUtf8 roleNm) (_ : _ : _) -> throw $ badRequest "A user cannot have more than one role." badRequest :: Text -> Scim.ScimError @@ -511,7 +534,7 @@ createValidScimUser :: Member GalleyAPIAccess r, Member BrigAPIAccess r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r, + Member ScimUserMetaStore r, Member SAMLUserStore r, Member IdPConfigStore r ) => @@ -581,11 +604,11 @@ createValidScimUser tokeninfo@ScimTokenInfo {stiTeam} vsu@(ST.ValidScimUser {..} acc <- lift (BrigAPIAccess.getAccount Intra.WithPendingInvitations buid) >>= maybe (throwError $ Scim.serverError "Server error: user vanished") pure - synthesizeStoredUser acc externalId + synthesizeStoredUser acc externalId (Just (emailType, emailPrimary)) lift $ Logger.debug ("createValidScimUser: spar says " <> show storedUser) -- {(arianvp): these two actions we probably want to make transactional.} - createValidScimUserSpar stiTeam buid storedUser externalId + createValidScimUserSpar stiTeam buid externalId -- If applicable, trigger email validation procedure on brig. -- FUTUREWORK: validate fallback emails? @@ -614,23 +637,22 @@ createValidScimUser tokeninfo@ScimTokenInfo {stiTeam} vsu@(ST.ValidScimUser {..} externalIdTakenError :: Text -> Scim.ScimError externalIdTakenError msg = Scim.conflict {Scim.detail = Just ("ExternalId is already taken: " <> msg)} --- | Store scim timestamps, saml credentials, scim externalId locally in spar. Table +-- | Store saml credentials, scim externalId locally in spar. Table -- `spar.scim_external` gets an entry iff there is no `UserRef`: if there is, we don't do a -- lookup in that table either, but compute the `externalId` from the `UserRef`. +-- +-- (Scim timestamps and email metadata are written by 'synthesizeStoredUser'.) createValidScimUserSpar :: forall m r. ( (m ~ Scim.ScimHandler (Sem r)), Member ScimExternalIdStore r, - Member ScimUserTimesStore r, Member SAMLUserStore r ) => TeamId -> UserId -> - Scim.StoredUser ST.SparTag -> ST.ValidScimId -> m () -createValidScimUserSpar stiTeam uid storedUser veid = lift $ do - ScimUserTimesStore.write storedUser +createValidScimUserSpar stiTeam uid veid = lift $ do ScimExternalIdStore.insert stiTeam veid.validScimIdExternal uid for_ (justThere veid.validScimIdAuthInfo) (`SAMLUserStore.insert` uid) @@ -645,7 +667,7 @@ updateValidScimUser :: Member GalleyAPIAccess r, Member BrigAPIAccess r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r, + Member ScimUserMetaStore r, Member IdPConfigStore r, Member SAMLUserStore r ) => @@ -688,6 +710,17 @@ updateValidScimUser tokinfo@ScimTokenInfo {stiTeam} uid nvsu = when (oldValidScimUser.externalId /= newValidScimUser.externalId) $ updateVsuUref stiTeam uid (oldValidScimUser.externalId) (newValidScimUser.externalId) + -- An email-only change does not alter the externalId, so + -- 'updateVsuUref' (above, which only runs on an externalId change) + -- would not propagate the new email to Brig. Validate it here in + -- that case; when the externalId changes too, 'updateVsuUref' has + -- already validated the email, so we skip it here to avoid a + -- duplicate call. + when + ( oldValidScimUser.externalId == newValidScimUser.externalId + && vsUserEmail oldValidScimUser /= vsUserEmail newValidScimUser + ) + $ forM_ (vsUserEmail newValidScimUser) (Spar.App.validateEmail (Just stiTeam) uid) when (newValidScimUser.name /= oldValidScimUser.name) $ BrigAPIAccess.setName uid (newValidScimUser.name) @@ -710,7 +743,7 @@ updateValidScimUser tokinfo@ScimTokenInfo {stiTeam} uid nvsu = let new = ST.scimActiveFlagToAccountStatus old (Just $ newValidScimUser.active) when (new /= old) $ BrigAPIAccess.setStatus uid new - ScimUserTimesStore.write newScimStoredUser + ScimUserMetaStore.write newValidScimUser.emailType newValidScimUser.emailPrimary newScimStoredUser Scim.getUser tokinfo uid updateVsuUref :: @@ -795,7 +828,7 @@ deleteScimUser :: ( Member (Logger (Msg -> Msg)) r, Member BrigAPIAccess r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r, + Member ScimUserMetaStore r, Member SAMLUserStore r, Member IdPConfigStore r ) => @@ -844,7 +877,7 @@ deleteScimUser tokeninfo@ScimTokenInfo {stiTeam, stiIdP} uid = ( Member IdPConfigStore r, Member SAMLUserStore r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r + Member ScimUserMetaStore r ) => User -> Scim.ScimHandler (Sem r) () @@ -864,7 +897,7 @@ deleteScimUser tokeninfo@ScimTokenInfo {stiTeam, stiIdP} uid = Right veid -> lift $ do for_ (justThere veid.validScimIdAuthInfo) (SAMLUserStore.delete uid) ScimExternalIdStore.delete stiTeam veid.validScimIdExternal - lift $ ScimUserTimesStore.delete uid + lift $ ScimUserMetaStore.delete uid ---------------------------------------------------------------------------- -- Utilities @@ -963,6 +996,14 @@ assertHandleNotUsedElsewhere uid hndl = do -- | Helper function that translates a given brig user into a 'Scim.StoredUser', with some -- effects like updating the 'ManagedBy' field in brig and storing creation and update time -- stamps. +-- +-- @mEmailMeta@: @'Just' (ty, pr)@ forces the email metadata (used by user creation, where +-- the store row does not exist yet); 'Nothing' uses the stored metadata. +-- +-- NB: callers MUST only pass 'Just' when the store row is known to be absent: +-- metadata is persisted only when the row is written (i.e. it did not exist), +-- so forcing metadata on an existing row would echo values that were never +-- stored. synthesizeStoredUser :: forall r. ( Member (Input Opts) r, @@ -970,12 +1011,13 @@ synthesizeStoredUser :: Member (Logger (Msg -> Msg)) r, Member BrigAPIAccess r, Member GalleyAPIAccess r, - Member ScimUserTimesStore r + Member ScimUserMetaStore r ) => User -> ST.ValidScimId -> + Maybe (Maybe Text, Maybe Bool) -> Scim.ScimHandler (Sem r) (Scim.StoredUser ST.SparTag) -synthesizeStoredUser acc veid = +synthesizeStoredUser acc veid mEmailMeta = logScim ( logFunction "Spar.Scim.User.synthesizeStoredUser" . logUser (userId acc) @@ -988,33 +1030,39 @@ synthesizeStoredUser acc veid = let uid = userId acc accStatus = acc.userStatus - let readState :: Sem r (RI.RichInfo, Maybe (UTCTimeMillis, UTCTimeMillis), URIBS.URI, Role) + let readState :: Sem r (RI.RichInfo, Maybe ScimUserMetaStore.ScimUserMeta, URIBS.URI, Role) readState = (,,,) <$> BrigAPIAccess.getRichInfo uid - <*> ScimUserTimesStore.read uid + <*> ScimUserMetaStore.read uid <*> inputs scimBaseUri <*> getRole - let writeState :: Maybe (UTCTimeMillis, UTCTimeMillis) -> ManagedBy -> RI.RichInfo -> Scim.StoredUser ST.SparTag -> Sem r () - writeState oldAccessTimes oldManagedBy oldRichInfo storedUser = do - when (isNothing oldAccessTimes) $ - ScimUserTimesStore.write storedUser - when (oldManagedBy /= ManagedByScim) $ + (richInfo, accessTimes, baseuri, role) <- lift readState + now <- toUTCTimeMillis <$> lift Now.get + let (createdAt, lastUpdatedAt, storedEmailType, storedEmailPrimary) = case accessTimes of + Just t -> (t.scimUserMetaCreated, t.scimUserMetaLastUpdated, t.scimUserMetaEmailType, t.scimUserMetaEmailPrimary) + Nothing -> (now, now, Nothing, Nothing) + (emailType, emailPrimary) = fromMaybe (storedEmailType, storedEmailPrimary) mEmailMeta + + let writeState :: ManagedBy -> RI.RichInfo -> Scim.StoredUser ST.SparTag -> Sem r () + writeState oldManagedBy oldRichInfo storedUser = do + when (isNothing accessTimes) $ + ScimUserMetaStore.write emailType emailPrimary storedUser + when (oldManagedBy /= ManagedByScim) $ do BrigAPIAccess.setManagedBy uid ManagedByScim + -- Invalidate any pending email-address update: a SCIM-managed user's + -- email can only be changed through SCIM, so the pending update token + -- and the unvalidated email must be removed. + BrigAPIAccess.deletePendingEmailUpdate uid let newRichInfo = view ST.sueRichInfo . Scim.extra . Scim.value . Scim.thing $ storedUser when (oldRichInfo /= newRichInfo) $ BrigAPIAccess.setRichInfo uid newRichInfo - (richInfo, accessTimes, baseuri, role) <- lift readState - now <- toUTCTimeMillis <$> lift Now.get - let (createdAt, lastUpdatedAt) = fromMaybe (now, now) accessTimes - handle <- lift $ Intra.giveDefaultHandle acc let emails = - maybeToList $ - acc.userEmailUnvalidated <|> (emailIdentity =<< userIdentity acc) <|> justHere veid.validScimIdAuthInfo + acc.userEmailUnvalidated <|> (emailIdentity =<< userIdentity acc) <|> justHere veid.validScimIdAuthInfo storedUser <- synthesizeStoredUser' @@ -1022,6 +1070,8 @@ synthesizeStoredUser acc veid = veid acc.userDisplayName emails + emailType + emailPrimary handle richInfo accStatus @@ -1030,7 +1080,7 @@ synthesizeStoredUser acc veid = baseuri acc.userLocale (Just role) - lift $ writeState accessTimes acc.userManagedBy richInfo storedUser + lift $ writeState acc.userManagedBy richInfo storedUser pure storedUser where getRole :: Sem r Role @@ -1043,7 +1093,9 @@ synthesizeStoredUser' :: UserId -> ST.ValidScimId -> Name -> - [EmailAddress] -> + Maybe EmailAddress -> + Maybe Text -> + Maybe Bool -> Handle -> RI.RichInfo -> AccountStatus -> @@ -1053,7 +1105,7 @@ synthesizeStoredUser' :: Locale -> Maybe Role -> m (Scim.StoredUser ST.SparTag) -synthesizeStoredUser' uid veid dname emails handle richInfo accStatus createdAt lastUpdatedAt baseuri locale mbRole = do +synthesizeStoredUser' uid veid dname emails emailType emailPrimary handle richInfo accStatus createdAt lastUpdatedAt baseuri locale mbRole = do let scimUser :: Scim.User ST.SparTag scimUser = synthesizeScimUser @@ -1063,6 +1115,8 @@ synthesizeStoredUser' uid veid dname emails handle richInfo accStatus createdAt checker to make sure this exists, so we add it here redundantly, without the 'Maybe'. -}, ST.emails = emails, + ST.emailType = emailType, + ST.emailPrimary = emailPrimary, ST.name = dname, ST.richInfo = richInfo, ST.active = ST.scimActiveFlagFromAccountStatus accStatus, @@ -1082,13 +1136,31 @@ synthesizeScimUser info = Scim.roles = maybe [] - ( (: []) - . Text.decodeUtf8With lenientDecode - . toStrict - . toByteString + ( \role -> + [ Scim.Role.Role + { Scim.Role.value = + Just + . Text.decodeUtf8With lenientDecode + . toStrict + . toByteString + $ role, + Scim.Role.typ = Nothing, + Scim.Role.display = Nothing, + Scim.Role.primary = Nothing + } + ] ) (info.role), - Scim.emails = (\e -> Scim.Email.Email Nothing (Scim.Email.EmailAddress e) Nothing) <$> info.emails + -- The type/primary metadata of the stored email entry are persisted in + -- @spar.scim_user_times@ (see "Wire.ScimUserMetaStore") and echoed exactly + -- as the IdP sent them; nothing is synthesized. Users provisioned before + -- the metadata existed (or by providers that send none) echo neither + -- field, so value-path filters like @emails[type eq \"work\"]@ only match + -- when that type was actually supplied — by design (strict round-trip). + Scim.emails = + maybeToList $ + (\e -> Scim.Email.Email info.emailType (Scim.Email.EmailAddress e) (Scim.ScimBool <$> info.emailPrimary)) + <$> info.emails } -- TODO: now write a test, either in /integration or in spar, whichever is easier. (spar) @@ -1101,7 +1173,7 @@ getUserById :: Member Now r, Member SAMLUserStore r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r + Member ScimUserMetaStore r ) => Maybe IdP -> TeamId -> @@ -1113,12 +1185,12 @@ getUserById midp stiTeam uid = do mbNewVeid = Intra.newVeidFromBrigUser brigUser ((^. SAML.idpMetadata . SAML.edIssuer) <$> midp) case mbNewVeid of Right veid | userTeam brigUser == Just stiTeam -> lift $ do - storedUser :: Scim.StoredUser ST.SparTag <- synthesizeStoredUser brigUser veid + storedUser :: Scim.StoredUser ST.SparTag <- synthesizeStoredUser brigUser veid Nothing -- if we get a user from brig that hasn't been touched by scim yet, we call this -- function to move it under scim control. assertExternalIdNotUsedElsewhere stiTeam veid uid handleVeidChange brigUser mbOldVeid veid - createValidScimUserSpar stiTeam uid storedUser veid + createValidScimUserSpar stiTeam uid veid pure storedUser _ -> Applicative.empty where @@ -1130,6 +1202,8 @@ getUserById midp stiTeam uid = do -- set managed_by when (userManagedBy brigUser /= ManagedByScim) do lift $ BrigAPIAccess.setManagedBy uid ManagedByScim + -- Invalidate any pending email-address update (see comment above). + lift $ BrigAPIAccess.deletePendingEmailUpdate uid -- remove dangling entry from spar.user_v2 table (cassandra) case mbOldVeid of Just oldVeid | ST.veidUref newVeid /= ST.veidUref oldVeid -> do @@ -1145,7 +1219,7 @@ scimFindUserByHandle :: Member Now r, Member SAMLUserStore r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r + Member ScimUserMetaStore r ) => Maybe IdP -> TeamId -> @@ -1171,7 +1245,7 @@ scimFindUserByExternalId :: Member Now r, Member SAMLUserStore r, Member ScimExternalIdStore r, - Member ScimUserTimesStore r + Member ScimUserMetaStore r ) => Maybe IdP -> TeamId -> diff --git a/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs b/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs index 3f83e9b3459..d1e930d49eb 100644 --- a/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs +++ b/services/spar/src/Spar/Sem/DefaultSsoCode/Spec.hs @@ -24,10 +24,10 @@ import Imports import Polysemy import Polysemy.Check import SAML2.WebSSO.Types -import qualified Spar.Sem.DefaultSsoCode as E import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck +import qualified Wire.DefaultSsoStore as E propsForInterpreter :: (PropConstraints r f) => diff --git a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs b/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs index 1a8805e8afe..1f166a7fcd8 100644 --- a/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs +++ b/services/spar/src/Spar/Sem/IdPRawMetadataStore/Spec.hs @@ -24,10 +24,10 @@ import Imports import Polysemy import Polysemy.Check import SAML2.WebSSO.Types (IdPId) -import qualified Spar.Sem.IdPRawMetadataStore as E import Test.Hspec import Test.Hspec.QuickCheck import Test.QuickCheck +import qualified Wire.IdPRawMetadataStore as E class (Arbitrary IdPId, CoArbitrary IdPId, Arbitrary Text, CoArbitrary Text, Functor f, Member E.IdPRawMetadataStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) => diff --git a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs b/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs deleted file mode 100644 index eab1ba7d47f..00000000000 --- a/services/spar/src/Spar/Sem/ScimExternalIdStore/Spec.hs +++ /dev/null @@ -1,203 +0,0 @@ -{-# LANGUAGE QuantifiedConstraints #-} -{-# OPTIONS_GHC -fplugin=Polysemy.Plugin #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Spar.Sem.ScimExternalIdStore.Spec (propsForInterpreter) where - -import Data.Id -import Imports -import Polysemy -import Polysemy.Check -import Spar.Scim.Types (ScimUserCreationStatus) -import qualified Spar.Sem.ScimExternalIdStore as E -import Test.Hspec -import Test.Hspec.QuickCheck -import Test.QuickCheck - -propsForInterpreter :: - (PropConstraints r f) => - String -> - (forall a. f a -> a) -> - (forall a. Sem r a -> IO (f a)) -> - Spec -propsForInterpreter interpreter extract lower = do - describe interpreter $ do - prop "delete/delete" $ prop_deleteDelete Nothing lower - prop "delete/lookup" $ prop_deleteLookup (Just $ show . void . extract) lower - prop "delete/insert" $ prop_deleteInsert Nothing lower - prop "lookup/insert" $ prop_lookupInsert Nothing lower - prop "insert/delete" $ prop_insertDelete Nothing lower - prop "insert/lookup" $ prop_insertLookup (Just $ show . void . extract) lower - prop "insert/insert" $ prop_insertInsert (Just $ show . void . extract) lower - --- FUTUREWORK: Add prop tests for missing operations - --- | All the constraints we need to generalize properties in this module. --- A regular type synonym doesn't work due to dreaded impredicative --- polymorphism. -class - (Arbitrary UserId, CoArbitrary UserId, Arbitrary ScimUserCreationStatus, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) => - PropConstraints r f - -instance - (CoArbitrary UserId, CoArbitrary ScimUserCreationStatus, Functor f, Member E.ScimExternalIdStore r, forall z. (Show z) => Show (f z), forall z. (Eq z) => Eq (f z)) => - PropConstraints r f - -prop_insertLookup :: - (PropConstraints r f) => - Maybe (f (Maybe UserId) -> String) -> - (forall a. Sem r a -> IO (f a)) -> - Property -prop_insertLookup = - prepropLaw @'[E.ScimExternalIdStore] $ do - tid <- arbitrary - email <- arbitrary - uid <- arbitrary - pure $ - simpleLaw - ( do - E.insert tid email uid - E.lookup tid email - ) - ( do - E.insert tid email uid - pure (Just uid) - ) - -prop_lookupInsert :: - (PropConstraints r f) => - Maybe (f () -> String) -> - (forall a. Sem r a -> IO (f a)) -> - Property -prop_lookupInsert = - prepropLaw @'[E.ScimExternalIdStore] $ do - tid <- arbitrary - email <- arbitrary - pure $ - simpleLaw - ( do - E.lookup tid email >>= maybe (pure ()) (E.insert tid email) - ) - ( do - pure () - ) - -prop_insertDelete :: - (PropConstraints r f) => - Maybe (f () -> String) -> - (forall a. Sem r a -> IO (f a)) -> - Property -prop_insertDelete = - prepropLaw @'[E.ScimExternalIdStore] $ do - tid <- arbitrary - email <- arbitrary - uid <- arbitrary - pure $ - simpleLaw - ( do - E.insert tid email uid - E.delete tid email - ) - ( do - E.delete tid email - ) - -prop_deleteInsert :: - (PropConstraints r f) => - Maybe (f () -> String) -> - (forall a. Sem r a -> IO (f a)) -> - Property -prop_deleteInsert = - prepropLaw @'[E.ScimExternalIdStore] $ do - tid <- arbitrary - email <- arbitrary - uid <- arbitrary - pure $ - simpleLaw - ( do - E.delete tid email - E.insert tid email uid - ) - ( do - E.insert tid email uid - ) - -prop_insertInsert :: - (PropConstraints r f) => - Maybe (f (Maybe UserId) -> String) -> - (forall a. Sem r a -> IO (f a)) -> - Property -prop_insertInsert = - prepropLaw @'[E.ScimExternalIdStore] $ do - tid <- arbitrary - email <- arbitrary - uid <- arbitrary - uid' <- arbitrary - pure $ - simpleLaw - ( do - E.insert tid email uid - E.insert tid email uid' - E.lookup tid email - ) - ( do - E.insert tid email uid' - E.lookup tid email - ) - -prop_deleteDelete :: - (PropConstraints r f) => - Maybe (f () -> String) -> - (forall a. Sem r a -> IO (f a)) -> - Property -prop_deleteDelete = - prepropLaw @'[E.ScimExternalIdStore] $ do - tid <- arbitrary - email <- arbitrary - pure $ - simpleLaw - ( do - E.delete tid email - E.delete tid email - ) - ( do - E.delete tid email - ) - -prop_deleteLookup :: - (PropConstraints r f) => - Maybe (f (Maybe UserId) -> String) -> - (forall a. Sem r a -> IO (f a)) -> - Property -prop_deleteLookup = - prepropLaw @'[E.ScimExternalIdStore] $ do - tid <- arbitrary - email <- arbitrary - uid <- arbitrary - pure $ - Law - { lawLhs = do - E.delete tid email - E.lookup tid email, - lawRhs = do - E.delete tid email - pure Nothing, - lawPrelude = [E.insert tid email uid], - lawPostlude = [] @(Sem _ ()) - } diff --git a/services/spar/src/Spar/Sem/ScimUserTimesStore/Cassandra.hs b/services/spar/src/Spar/Sem/ScimUserTimesStore/Cassandra.hs deleted file mode 100644 index bd3c45cbce1..00000000000 --- a/services/spar/src/Spar/Sem/ScimUserTimesStore/Cassandra.hs +++ /dev/null @@ -1,87 +0,0 @@ --- Disabling to stop warnings on HasCallStack -{-# OPTIONS_GHC -Wno-redundant-constraints #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Spar.Sem.ScimUserTimesStore.Cassandra - ( scimUserTimesStoreToCassandra, - ) -where - -import Cassandra as Cas -import Data.Id -import Data.Json.Util (UTCTimeMillis, toUTCTimeMillis) -import Imports -import Polysemy -import Spar.Sem.ScimUserTimesStore (ScimUserTimesStore (..)) -import Web.Scim.Schema.Common (WithId (..)) -import Web.Scim.Schema.Meta (Meta (..), WithMeta (..)) - -scimUserTimesStoreToCassandra :: forall m r a. (MonadClient m, Member (Embed m) r) => Sem (ScimUserTimesStore ': r) a -> Sem r a -scimUserTimesStoreToCassandra = - interpret $ - embed @m . \case - Write wm -> writeScimUserTimes wm - Read uid -> readScimUserTimes uid - ReadMulti uids -> readScimUserTimesMulti uids - Delete uid -> deleteScimUserTimes uid - ----------------------------------------------------------------------- --- SCIM user records --- --- docs/developer/scim/storage.md {#DevScimStorageUsers} - --- | Store creation and last-update time from the scim metadata under a user id. -writeScimUserTimes :: (HasCallStack, MonadClient m) => WithMeta (WithId UserId a) -> m () -writeScimUserTimes (WithMeta meta (WithId uid _)) = - retry x5 . write ins $ - params - LocalQuorum - ( uid, - toUTCTimeMillis $ created meta, - toUTCTimeMillis $ lastModified meta - ) - where - ins :: PrepQuery W (UserId, UTCTimeMillis, UTCTimeMillis) () - ins = "INSERT INTO scim_user_times (uid, created_at, last_updated_at) VALUES (?, ?, ?)" - --- | Read creation and last-update time from database for a given user id. -readScimUserTimes :: (HasCallStack, MonadClient m) => UserId -> m (Maybe (UTCTimeMillis, UTCTimeMillis)) -readScimUserTimes uid = do - retry x1 . query1 sel $ params LocalQuorum (Identity uid) - where - sel :: PrepQuery R (Identity UserId) (UTCTimeMillis, UTCTimeMillis) - sel = "SELECT created_at, last_updated_at FROM scim_user_times WHERE uid = ?" - -readScimUserTimesMulti :: (HasCallStack, MonadClient m) => [UserId] -> m [(UserId, UTCTimeMillis, UTCTimeMillis)] -readScimUserTimesMulti uid = do - retry x1 . query sel $ params LocalQuorum (Identity uid) - where - sel :: PrepQuery R (Identity [UserId]) (UserId, UTCTimeMillis, UTCTimeMillis) - sel = "SELECT uid, created_at, last_updated_at FROM scim_user_times WHERE uid IN ?" - --- | Delete a SCIM user's access times by id. --- You'll also want to ensure they are deleted in Brig and in the SAML Users table. -deleteScimUserTimes :: - (HasCallStack, MonadClient m) => - UserId -> - m () -deleteScimUserTimes uid = retry x5 . write del $ params LocalQuorum (Identity uid) - where - del :: PrepQuery W (Identity UserId) () - del = "DELETE FROM scim_user_times WHERE uid = ?" diff --git a/services/spar/test-integration/Test/Spar/APISpec.hs b/services/spar/test-integration/Test/Spar/APISpec.hs index 315d798378c..dd7935d210f 100644 --- a/services/spar/test-integration/Test/Spar/APISpec.hs +++ b/services/spar/test-integration/Test/Spar/APISpec.hs @@ -1351,7 +1351,7 @@ specProvisionScimAndSAMLUserWithRole = do let testCreateUserWithRole role = do scimUser <- do u <- ScimT.randomScimUser - pure $ u {Scim.roles = [cs $ toByteString $ role]} + pure $ u {Scim.roles = ScimT.mkScimRoles [cs $ toByteString $ role]} uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser ScimT.checkTeamMembersRole tid owner uid role mapM_ testCreateUserWithRole [minBound .. maxBound] @@ -1366,24 +1366,32 @@ specProvisionScimAndSAMLUserWithRole = do (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta scimUser <- do u <- ScimT.randomScimUser - pure $ u {Scim.roles = ["member", "admin"]} + pure $ u {Scim.roles = ScimT.mkScimRoles ["member", "admin"]} ScimT.createUser' tok scimUser !!! do const 400 === statusCode - const (Just "A user cannot have more than one role.") =~= responseBody + ScimT.mkScimErrorResp + (Just "A user cannot have more than one role. (post)") + (Just "invalidValue") + "400" + === responseBody it "create user - fail if role name cannot be parsed correctly" $ do (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta scimUser <- do u <- ScimT.randomScimUser - pure $ u {Scim.roles = ["president"]} + pure $ u {Scim.roles = ScimT.mkScimRoles ["president"]} ScimT.createUser' tok scimUser !!! do const 400 === statusCode - const (Just "The role 'president' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody + ScimT.mkScimErrorResp + (Just "The role 'president' is not valid. Valid roles are owner, admin, member, partner. (post)") + (Just "invalidValue") + "400" + === responseBody it "update user" $ do (tok, (owner, tid, _idp, (_, _privcreds))) <- ScimT.registerIdPAndScimTokenWithMeta scimUserWithDefaultRole <- ScimT.randomScimUser uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUserWithDefaultRole let testUpdateUserWithRole role = do - let scimUserWithRole = scimUserWithDefaultRole {Scim.roles = [cs $ toByteString $ role]} + let scimUserWithRole = scimUserWithDefaultRole {Scim.roles = ScimT.mkScimRoles [cs $ toByteString $ role]} _ <- ScimT.updateUser tok uid scimUserWithRole ScimT.checkTeamMembersRole tid owner uid role mapM_ testUpdateUserWithRole [minBound .. maxBound] @@ -1393,7 +1401,7 @@ specProvisionScimAndSAMLUserWithRole = do testUpdateUserWithDefaultRole role = do scimUser <- do u <- ScimT.randomScimUser - pure $ u {Scim.roles = [cs $ toByteString $ role]} + pure $ u {Scim.roles = ScimT.mkScimRoles [cs $ toByteString $ role]} uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser _ <- ScimT.updateUser tok uid (scimUser {Scim.roles = []}) ScimT.checkTeamMembersRole tid owner uid role @@ -1402,16 +1410,24 @@ specProvisionScimAndSAMLUserWithRole = do (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta scimUser <- ScimT.randomScimUser uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser - ScimT.updateUser' tok uid (scimUser {Scim.roles = ["admin", "member"]}) !!! do + ScimT.updateUser' tok uid (scimUser {Scim.roles = ScimT.mkScimRoles ["admin", "member"]}) !!! do const 400 === statusCode - const (Just "A user cannot have more than one role.") =~= responseBody + ScimT.mkScimErrorResp + (Just "A user cannot have more than one role. (put)") + (Just "invalidValue") + "400" + === responseBody it "updated user - fail if role name cannot be parsed correctly" $ do (tok, _) <- ScimT.registerIdPAndScimTokenWithMeta scimUser <- ScimT.randomScimUser uid <- ScimT.scimUserId <$> ScimT.createUser tok scimUser - ScimT.updateUser' tok uid (scimUser {Scim.roles = ["hamlet"]}) !!! do + ScimT.updateUser' tok uid (scimUser {Scim.roles = ScimT.mkScimRoles ["hamlet"]}) !!! do const 400 === statusCode - const (Just "The role 'hamlet' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody + ScimT.mkScimErrorResp + (Just "The role 'hamlet' is not valid. Valid roles are owner, admin, member, partner. (put)") + (Just "invalidValue") + "400" + === responseBody specAux :: SpecWith TestEnv specAux = do diff --git a/services/spar/test-integration/Test/Spar/AppSpec.hs b/services/spar/test-integration/Test/Spar/AppSpec.hs index 07263bbb355..88183b9ffd3 100644 --- a/services/spar/test-integration/Test/Spar/AppSpec.hs +++ b/services/spar/test-integration/Test/Spar/AppSpec.hs @@ -175,7 +175,7 @@ requestAccessVerdict idp isGranted mkAuthnReq = do then SAML.AccessGranted uref else SAML.AccessDenied [DeniedNoBearerConfSubj, DeniedNoAuthnStatement] outcome <- do - runSpar $ Spar.verdictHandler (authnresp ^. rspPayload) verdict idp + runSpar $ Spar.verdictHandler (authnresp ^. rspPayload) verdict idp Nothing let loc :: URI.URI loc = maybe (error "no location") (either error id . SAML.parseURI' . cs) diff --git a/services/spar/test-integration/Test/Spar/DataSpec.hs b/services/spar/test-integration/Test/Spar/DataSpec.hs index 4251a61c859..aee3e8cc2f7 100644 --- a/services/spar/test-integration/Test/Spar/DataSpec.hs +++ b/services/spar/test-integration/Test/Spar/DataSpec.hs @@ -35,7 +35,6 @@ import qualified Spar.Sem.AReqIDStore as AReqIDStore import qualified Spar.Sem.AssIDStore as AssIDStore import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import qualified Spar.Sem.ScimTokenStore as ScimTokenStore -import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore import Type.Reflection (typeRep) import URI.ByteString.QQ (uri) import Util.Core @@ -46,6 +45,7 @@ import Web.Scim.Schema.Meta as Scim.Meta import Wire.API.User.IdentityProvider import Wire.API.User.Saml import qualified Wire.IdPConfigStore as IdPEffect +import qualified Wire.VerdictFormatStore as VerdictFormatStore spec :: SpecWith TestEnv spec = do diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index 1df3cd6ff36..46307a710d3 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -36,7 +36,6 @@ import Control.Monad.Except (MonadError (throwError)) import Control.Monad.Random (randomRIO) import Control.Monad.Trans.Except import Control.Monad.Trans.Maybe -import qualified Data.Aeson import qualified Data.Aeson as Aeson import Data.Aeson.Lens (key, _String) import Data.Aeson.QQ (aesonQQ) @@ -56,7 +55,6 @@ import Data.Text.Encoding (decodeUtf8, encodeUtf8) import qualified Data.Vector as V import qualified Data.ZAuth.Token as ZAuth import Imports -import qualified Network.Wai.Utilities.Error as Wai import Polysemy import Polysemy.Error import qualified SAML2.WebSSO as SAML @@ -69,8 +67,6 @@ import Spar.Scim import Spar.Scim.Types (normalizeLikeStored) import qualified Spar.Scim.User as SU import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import qualified Spar.Sem.ScimUserTimesStore as ScimUserTimesStore import Test.Tasty.HUnit ((@?=)) import qualified Text.XML.DSig as SAML import Util @@ -96,6 +92,8 @@ import Wire.API.User.RichInfo import qualified Wire.API.User.Scim as Spar.Types import qualified Wire.API.User.Search as Search import qualified Wire.BrigAPIAccess as BrigAPIAccess +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore +import qualified Wire.ScimUserMetaStore as ScimUserMetaStore -- | Tests for @\/scim\/v2\/Users@. spec :: SpecWith TestEnv @@ -652,9 +650,11 @@ testCreateUserWithPass = do user <- randomScimUser <&> \u -> u {Scim.User.password = Just "geheim"} createUser_ (Just tok) user (env ^. teSpar) !!! do const 400 === statusCode - -- TODO: write a FAQ entry in wire-docs, reference it in the error description. - -- TODO: yes, we should just test for error labels consistently, i know... - const (Just "Setting user passwords is not supported for security reasons.") =~= responseBody + mkScimErrorResp + (Just "Setting user passwords is not supported for security reasons. (post)") + (Just "invalidValue") + "400" + === responseBody testCreateUserNoIdPInvalidRoles :: TestSpar () testCreateUserNoIdPInvalidRoles = do @@ -668,20 +668,28 @@ testCreateUserNoIdPInvalidRoles = do randomScimUser <&> \u -> u { Scim.User.externalId = Just $ fromEmail email, - Scim.User.roles = cs . toByteString <$> [RoleMember, RoleExternalPartner] + Scim.User.roles = mkScimRoles $ cs . toByteString <$> [RoleMember, RoleExternalPartner] } createUser' tok scimUserTooManyRoles !!! do const 400 === statusCode - const (Just "A user cannot have more than one role.") =~= responseBody + mkScimErrorResp + (Just "A user cannot have more than one role. (post)") + (Just "invalidValue") + "400" + === responseBody scimUserInvalidRole <- randomScimUser <&> \u -> u { Scim.User.externalId = Just $ fromEmail email, - Scim.User.roles = ["foobar"] + Scim.User.roles = mkScimRoles ["foobar"] } createUser' tok scimUserInvalidRole !!! do const 400 === statusCode - const (Just "The role 'foobar' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody + mkScimErrorResp + (Just "The role 'foobar' is not valid. Valid roles are owner, admin, member, partner. (post)") + (Just "invalidValue") + "400" + === responseBody testCreateUserNoIdPWithRoles :: TestSpar () testCreateUserNoIdPWithRoles = do @@ -699,7 +707,7 @@ testCreateUserNoIdPWithRole brig tid owner tok role = do randomScimUser <&> \u -> u { Scim.User.externalId = Just $ fromEmail email, - Scim.User.roles = [cs $ toByteString role] + Scim.User.roles = mkScimRoles [cs $ toByteString role] } scimStoredUser <- createUser tok scimUser let userid = scimUserId scimStoredUser @@ -713,7 +721,7 @@ testCreateUserNoIdPWithRole brig tid owner tok role = do -- FUTUREWORK: if this is not the desired behavior, have to handle this in the `getUser` handler: -- - if the user has a pending invitation, we have to look up the role in the invitation table -- by doing an rpc to brig - liftIO $ Scim.User.roles usr `shouldBe` [cs $ toByteString defaultRole] + liftIO $ scimRoleValues (Scim.User.roles usr) `shouldBe` [cs $ toByteString defaultRole] -- now external ID can differ from email, so emails are also returned liftIO $ (\(Scim.Email.Email _ e _) -> Scim.Email.unEmailAddress e) <$> Scim.User.emails usr `shouldBe` [email] liftIO $ Scim.User.externalId usr `shouldBe` (Just (fromEmail email)) @@ -854,6 +862,11 @@ testCreateUserNoIdPNoEmail = do user <- randomScimUser <&> \u -> u {Scim.User.externalId = Just "notanemail"} createUser_ (Just tok) user (env ^. teSpar) !!! do const 400 === statusCode + mkScimErrorResp + (Just "Could not process externalId. Please check: (1) does the scim user contain a valid email address? (2) did you associate your scim token with a SAML IdP in wire?") + (Just "invalidValue") + "400" + === responseBody testCreateUserWithSamlIdP :: TestSpar () testCreateUserWithSamlIdP = do @@ -937,8 +950,13 @@ testExternalIdIsRequired = do user <- randomScimUser let user' = user {Scim.User.externalId = Nothing} (tok, _) <- registerIdPAndScimToken - createUser_ (Just tok) user' (env ^. teSpar) - !!! const 400 === statusCode + createUser_ (Just tok) user' (env ^. teSpar) !!! do + const 400 === statusCode + mkScimErrorResp + (Just "externalId is required") + (Just "invalidValue") + "400" + === responseBody -- The next line contains a mapping from this test to the following test standards: -- @SF.Provisioning @TSFI.RESTfulAPI @S2 @@ -950,8 +968,10 @@ testCreateRejectsInvalidHandle = do -- Create a user via SCIM user <- randomScimUser (tok, _) <- registerIdPAndScimToken - createUser_ (Just tok) (user {Scim.User.userName = "#invalid name"}) (env ^. teSpar) - !!! const 400 === statusCode + createUser_ (Just tok) (user {Scim.User.userName = "#invalid name"}) (env ^. teSpar) !!! do + const 400 === statusCode + mkScimErrorResp Nothing (Just "invalidValue") "400" + === responseBody -- @END @@ -967,11 +987,22 @@ testCreateRejectsTakenHandle = do -- Create and add a first user: success! _ <- createUser tokTeamA user1 -- Try to create different user with same handle in same team. - createUser_ (Just tokTeamA) (user2 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar) - !!! const 409 === statusCode + createUser_ (Just tokTeamA) (user2 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar) !!! do + const 409 === statusCode + mkScimErrorResp + (Just "userName is already taken") + (Just "uniqueness") + "409" + === responseBody + -- Try to create different user with same handle in different team. - createUser_ (Just tokTeamB) (user3 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar) - !!! const 409 === statusCode + createUser_ (Just tokTeamB) (user3 {Scim.User.userName = Scim.User.userName user1}) (env ^. teSpar) !!! do + const 409 === statusCode + mkScimErrorResp + (Just "userName is already taken") + (Just "uniqueness") + "409" + === responseBody -- | Test that user creation fails if the @externalId@ is already in use for given IdP. testCreateRejectsTakenExternalId :: Bool -> TestSpar () @@ -993,8 +1024,10 @@ testCreateRejectsTakenExternalId withidp = do _ <- createUser tok user1 -- Try to create different user with same @externalId@ in same team, and fail. user2 <- randomScimUser - createUser_ (Just tok) (user2 {Scim.User.externalId = Scim.User.externalId user1}) (env ^. teSpar) - !!! const 409 === statusCode + createUser_ (Just tok) (user2 {Scim.User.externalId = Scim.User.externalId user1}) (env ^. teSpar) !!! do + const 409 === statusCode + mkScimErrorResp Nothing (Just "uniqueness") "409" + === responseBody -- | Test that it's fine to have same @externalId@s for two users belonging to different IdPs. testCreateSameExternalIds :: TestSpar () @@ -1280,7 +1313,8 @@ testListProvisionedUsers = do (tok, _) <- registerIdPAndScimToken listUsers_ (Just tok) Nothing spar !!! do const 400 === statusCode - const (Just "tooMany") =~= responseBody + mkScimErrorResp Nothing (Just "tooMany") "400" + === responseBody testFindProvisionedUser :: TestSpar () testFindProvisionedUser = do @@ -1572,8 +1606,10 @@ testGetNoDeletedUsers = do -- Delete the user call $ deleteUserOnBrig (env ^. teBrig) userid -- Try to find the user - getUser_ (Just tok) userid (env ^. teSpar) - !!! const 404 === statusCode + getUser_ (Just tok) userid (env ^. teSpar) !!! do + const 404 === statusCode + mkScimErrorResp Nothing Nothing "404" + === responseBody -- TODO(arianvp): What does this mean; @fisx ?? pendingWith "TODO: delete via SCIM" @@ -1586,8 +1622,13 @@ testUserGetFailsWithNotFoundIfOutsideTeam = do (tokTeamB, _) <- registerIdPAndScimToken storedUser <- createUser tokTeamA user let userid = scimUserId storedUser - getUser_ (Just tokTeamB) userid (env ^. teSpar) - !!! const 404 === statusCode + getUser_ (Just tokTeamB) userid (env ^. teSpar) !!! do + const 404 === statusCode + mkScimErrorResp + (Just ("User " <> idToText userid <> " not found")) + Nothing + "404" + === responseBody {- does not find a non-scim-provisioned user: @@ -1694,8 +1735,10 @@ testUserUpdateFailsWithNotFoundIfOutsideTeam = do let userid = scimUserId storedUser -- Overwrite the user with another randomly-generated user user' <- randomScimUser - updateUser_ (Just tokTeamB) (Just userid) user' (env ^. teSpar) - !!! const 404 === statusCode + updateUser_ (Just tokTeamB) (Just userid) user' (env ^. teSpar) !!! do + const 404 === statusCode + mkScimErrorResp Nothing Nothing "404" + === responseBody -- | Test that @PUT@-ting the user and then @GET@-ting it returns the right thing. testScimSideIsUpdated :: TestSpar () @@ -1747,8 +1790,10 @@ testUpdateToExistingExternalIdFails = do env <- ask -- Should fail with 409 to denote that the given externalId is in use by a -- different user. - updateUser_ (Just tok) (Just $ scimUserId storedNewUser) updatedNewUser (env ^. teSpar) - !!! const 409 === statusCode + updateUser_ (Just tok) (Just $ scimUserId storedNewUser) updatedNewUser (env ^. teSpar) !!! do + const 409 === statusCode + mkScimErrorResp Nothing (Just "uniqueness") "409" + === responseBody -- | Test that updating still works when name and handle are not changed. -- @@ -1975,7 +2020,7 @@ testUpdateUserRole = do randomScimUser <&> \u -> u { Scim.User.externalId = Just $ fromEmail email, - Scim.User.roles = [cs $ toByteString initialRole] + Scim.User.roles = mkScimRoles [cs $ toByteString initialRole] } scimStoredUser <- createUser tok scimUser let userid = scimUserId scimStoredUser @@ -1987,7 +2032,7 @@ testUpdateUserRole = do Just inviteeCode <- call $ getInvitationCode brig tid inv.invitationId registerInvitation email userName inviteeCode True checkTeamMembersRole tid owner userid initialRole - _ <- updateUser tok userid (scimUser {Scim.User.roles = cs . toByteString <$> maybeToList mUpdatedRole}) + _ <- updateUser tok userid (scimUser {Scim.User.roles = mkScimRoles $ cs . toByteString <$> maybeToList mUpdatedRole}) checkTeamMembersRole tid owner userid targetRoleExpected ---------------------------------------------------------------------------- @@ -2024,6 +2069,7 @@ specPatchUser = do let userid = scimUserId storedUser storedUser' <- patchUser tok userid (PatchOp.PatchOp []) liftIO $ storedUser `shouldBe` storedUser' + it "can update userName" $ do (tok, _) <- registerIdPAndScimToken user <- randomScimUser @@ -2037,6 +2083,7 @@ specPatchUser = do [replaceAttrib "userName" userName] let user'' = Scim.value (Scim.thing storedUser') liftIO $ Scim.User.userName user'' `shouldBe` userName + it "can't update to someone else's userName" $ do env <- ask (tok, _) <- registerIdPAndScimToken @@ -2046,7 +2093,11 @@ specPatchUser = do let userid = scimUserId storedUser _ <- createUser tok user' let patchOp = PatchOp.PatchOp [replaceAttrib "userName" (Scim.User.userName user')] - patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 409 === statusCode + patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do + const 409 === statusCode + mkScimErrorResp Nothing (Just "uniqueness") "409" + === responseBody + it "can't update to someone else's externalId" $ do env <- ask (tok, _) <- registerIdPAndScimToken @@ -2056,13 +2107,21 @@ specPatchUser = do let userid = scimUserId storedUser _ <- createUser tok user' let patchOp = PatchOp.PatchOp [replaceAttrib "externalId" (Scim.User.externalId user')] - patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 409 === statusCode + patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do + const 409 === statusCode + mkScimErrorResp Nothing (Just "uniqueness") "409" + === responseBody + it "can't update a non-existing user" $ do env <- ask (tok, _) <- registerIdPAndScimToken userid <- liftIO $ randomId let patchOp = PatchOp.PatchOp [replaceAttrib "externalId" ("blah" :: Text)] - patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 404 === statusCode + patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do + const 404 === statusCode + mkScimErrorResp Nothing Nothing "404" + === responseBody + it "can update displayName" $ do (tok, _) <- registerIdPAndScimToken user <- randomScimUser @@ -2076,6 +2135,7 @@ specPatchUser = do [replaceAttrib "displayName" displayName] let user'' = Scim.value (Scim.thing storedUser') liftIO $ Scim.User.displayName user'' `shouldBe` displayName + it "can update externalId" $ do (tok, _) <- registerIdPAndScimToken user <- randomScimUser @@ -2089,10 +2149,15 @@ specPatchUser = do [replaceAttrib "externalId" externalId] let user'' = Scim.value . Scim.thing $ storedUser' liftIO $ Scim.User.externalId user'' `shouldBe` externalId + it "replace role works" $ testPatchRole replaceAttrib + it "add role works" $ testPatchRole addAttrib + it "replace with invalid input should fail" $ testPatchIvalidInput replaceAttrib + it "add with invalid input should fail" $ testPatchIvalidInput addAttrib + it "replacing every supported atttribute at once works" $ do (tok, _) <- registerIdPAndScimToken user <- randomScimUser @@ -2113,6 +2178,7 @@ specPatchUser = do liftIO $ Scim.User.externalId user'' `shouldBe` externalId liftIO $ Scim.User.userName user'' `shouldBe` userName liftIO $ Scim.User.displayName user'' `shouldBe` displayName + it "other valid attributes that we do not explicit support throw an error" $ do (tok, _) <- registerIdPAndScimToken user <- randomScimUser @@ -2120,8 +2186,11 @@ specPatchUser = do let userid = scimUserId storedUser env <- ask let patchOp = PatchOp.PatchOp [replaceAttrib "emails" ("hello" :: Text)] - patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) - !!! const 400 === statusCode + patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do + const 400 === statusCode + mkScimErrorResp Nothing (Just "invalidPath") "400" + === responseBody + it "invalid attributes are quietly ignored for now" $ do (tok, _) <- registerIdPAndScimToken user <- randomScimUser @@ -2129,8 +2198,11 @@ specPatchUser = do let userid = scimUserId storedUser env <- ask let patchOp = PatchOp.PatchOp [replaceAttrib "totallyBogus" ("hello" :: Text)] - patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) - !!! const 400 === statusCode + patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do + const 400 === statusCode + mkScimErrorResp Nothing (Just "invalidPath") "400" + === responseBody + -- NOTE: Remove at the moment actually never works! As all the fields -- we support are required in our book it "userName cannot be removed according to scim" $ do @@ -2140,7 +2212,11 @@ specPatchUser = do storedUser <- createUser tok user let userid = scimUserId storedUser let patchOp = PatchOp.PatchOp [removeAttrib "userName"] - patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 400 === statusCode + patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do + const 400 === statusCode + mkScimErrorResp Nothing (Just "mutability") "400" + === responseBody + it "displayName cannot be removed in spar (though possible in scim). Diplayname is required in Wire" $ do pendingWith "We default to the externalId when displayName is removed. lets keep that for now" @@ -2158,7 +2234,10 @@ specPatchUser = do storedUser <- createUser tok user let userid = scimUserId storedUser let patchOp = PatchOp.PatchOp [removeAttrib "externalId"] - patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! const 400 === statusCode + patchUser_ (Just tok) (Just userid) patchOp (env ^. teSpar) !!! do + const 400 === statusCode + mkScimErrorResp Nothing (Just "invalidValue") "400" + === responseBody testPatchIvalidInput :: (Text -> [Role] -> Operation) -> TestSpar () testPatchIvalidInput patchOp = do @@ -2172,14 +2251,22 @@ testPatchIvalidInput patchOp = do PatchOp.Operation PatchOp.Replace (Just (PatchOp.NormalPath (Filter.topLevelAttrPath "roles"))) - (Just $ Data.Aeson.Array $ V.singleton $ Data.Aeson.String "invalid-role") + (Just $ Aeson.Array $ V.singleton $ Aeson.String "invalid-role") patchUser' tok uid (PatchOp.PatchOp [patchWithInvalidRole]) !!! do const 400 === statusCode - const (Just "The role 'invalid-role' is not valid. Valid roles are owner, admin, member, partner.") =~= responseBody + mkScimErrorResp + (Just "The role 'invalid-role' is not valid. Valid roles are owner, admin, member, partner. (put)") + (Just "invalidValue") + "400" + === responseBody let patchWithTooManyRoles = patchOp "roles" [defaultRole, defaultRole] patchUser' tok uid (PatchOp.PatchOp [patchWithTooManyRoles]) !!! do const 400 === statusCode - const (Just "A user cannot have more than one role.") =~= responseBody + mkScimErrorResp + (Just "A user cannot have more than one role. (put)") + (Just "invalidValue") + "400" + === responseBody testPatchRole :: (Text -> [Role] -> Operation) -> TestSpar () testPatchRole replaceOrAdd = do @@ -2210,7 +2297,7 @@ createScimUserWithRole brig tid owner tok initialRole = do randomScimUser <&> \u -> u { Scim.User.externalId = Just $ fromEmail email, - Scim.User.roles = [cs $ toByteString initialRole] + Scim.User.roles = mkScimRoles [cs $ toByteString initialRole] } scimStoredUser <- createUser tok scimUser let userid = scimUserId scimStoredUser @@ -2233,8 +2320,8 @@ specDeleteUser = do it "responds with 405 (just making sure...)" $ do env <- ask (tok, _) <- registerIdPAndScimToken - deleteUser_ (Just tok) Nothing (env ^. teSpar) - !!! const 405 === statusCode + deleteUser_ (Just tok) Nothing (env ^. teSpar) !!! do + const 405 === statusCode describe "DELETE /Users/:id" $ do it "should delete user from brig, spar.scim_user_times, spar.user" $ do (tok, (_, _, idp)) <- registerIdPAndScimToken @@ -2256,10 +2343,10 @@ specDeleteUser = do aFewTimes (runSpar $ BrigAPIAccess.getAccount Intra.WithPendingInvitations uid) isNothing samlUser :: Maybe UserId <- aFewTimes (getUserIdViaRef' uref) isNothing - scimUser <- - aFewTimes (runSpar $ ScimUserTimesStore.read uid) isNothing + scimUserDeleted <- + aFewTimes (runSpar $ ScimUserMetaStore.read uid) isNothing liftIO $ - (brigUser, samlUser, scimUser) + (brigUser, samlUser, scimUserDeleted) `shouldBe` (Nothing, Nothing, Nothing) it "should respond with 204 on deletion (also indempotently)" $ do (tok, _) <- registerIdPAndScimToken @@ -2292,8 +2379,10 @@ specDeleteUser = do storedUser <- createUser tok user spar <- view teSpar let uid = scimUserId storedUser - deleteUser_ Nothing (Just uid) spar - !!! const 401 === statusCode + deleteUser_ Nothing (Just uid) spar !!! do + const 401 === statusCode + mkScimErrorResp Nothing Nothing "401" + === responseBody it "should always pretend to succeed, even if user exists in other team (does not leak information by diverging behavior)" $ do (tok, _) <- registerIdPAndScimToken user <- randomScimUser @@ -2313,8 +2402,10 @@ specDeleteUser = do let uid = scimUserId storedUser deleteUser_ (Just tok) (Just uid) spar !!! const 204 === statusCode - aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) - !!! const 404 === statusCode + aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) !!! do + const 404 === statusCode + mkScimErrorResp Nothing Nothing "404" + === responseBody deleteUser_ (Just tok) (Just uid) spar !!! const 204 === statusCode it "whether implemented or not, does *NOT EVER* respond with 5xx!" $ do @@ -2348,8 +2439,10 @@ specDeleteUser = do deleteUser_ (Just tok) (Just uid) spar !!! const 204 === statusCode - aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) - !!! const 404 === statusCode + aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) !!! do + const 404 === statusCode + mkScimErrorResp Nothing Nothing "404" + === responseBody context "user not touched via scim before" $ do it "works" $ do @@ -2368,8 +2461,10 @@ specDeleteUser = do !!! const 200 === statusCode deleteUser_ (Just tok) (Just uid) spar !!! const 204 === statusCode - aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) - !!! const 404 === statusCode + aFewTimes (getUser_ (Just tok) uid spar) ((== 404) . statusCode) !!! do + const 404 === statusCode + mkScimErrorResp Nothing Nothing "404" + === responseBody context "No IDP" $ do describe "Deleting a User" $ do @@ -2494,27 +2589,39 @@ specSCIMManaged = do resp <- call $ post (brig . path "/access" . forceCookie cky) resp -> Maybe LByteString + expectedResponseBody updatedThing = + -- NB: this is for requests to brig, not scim requests, so + -- the code-label-message schema is legit! + mkBrigErrorResp + [aesonQQ| + { + "code": 403, + "label": "managed-by-scim", + "message": #{"Updating " <> updatedThing <> " is not allowed, because it is managed by SCIM, or E2EId is enabled"} + }|] do newEmail <- randomEmail call $ changeEmailBrigCreds brig cky sessiontok newEmail !!! do - (fmap Wai.label . responseJsonEither @Wai.Error) === const (Right "managed-by-scim") statusCode === const 403 + expectedResponseBody "email" === responseBody do handleTxt <- randomAlphaNum call $ changeHandleBrig brig uid handleTxt !!! do - (fmap Wai.label . responseJsonEither @Wai.Error) === const (Right "managed-by-scim") statusCode === const 403 + expectedResponseBody "handle" === responseBody do displayName <- Name <$> randomAlphaNum let uupd = UserUpdate (Just displayName) Nothing Nothing Nothing Nothing call $ updateProfileBrig brig uid uupd !!! do - (fmap Wai.label . responseJsonEither @Wai.Error) === const (Right "managed-by-scim") statusCode === const 403 + expectedResponseBody "name" === responseBody + it "created_on should be filled in CSV export" $ do g <- view teGalley user <- randomScimUser @@ -2581,3 +2688,8 @@ executeTeamUserSearch brig teamid self mbSearchText = >= fmap Search.searchResults . responseJsonError + +-- | Assert the exact body of an error response from an endpoint that is /not/ scim: brig has +-- its own code-label-message error schema. For scim errors use 'mkScimErrorResp'. +mkBrigErrorResp :: Aeson.Value -> resp -> Maybe LByteString +mkBrigErrorResp val _ = Just (Aeson.encode val) diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index b172bac74b1..4fe1e23e92f 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -186,7 +186,6 @@ import qualified Spar.Intra.RpcApp as Intra import Spar.Options import Spar.Run import qualified Spar.Sem.SAMLUserStore as SAMLUserStore -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore import qualified System.Logger.Extended as Log import System.Random (randomRIO) import Test.Hspec hiding (it, pending, pendingWith, xit) @@ -218,6 +217,7 @@ import Wire.API.User.IdentityProvider import Wire.API.User.Scim import Wire.BrigAPIAccess (getAccount) import qualified Wire.IdPConfigStore as IdPConfigStore +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore -- | Call 'mkEnv' with options from config files. mkEnvFromOptions :: IO TestEnv diff --git a/services/spar/test-integration/Util/Scim.hs b/services/spar/test-integration/Util/Scim.hs index 9f8d1b1c023..aee2bf2aa75 100644 --- a/services/spar/test-integration/Util/Scim.hs +++ b/services/spar/test-integration/Util/Scim.hs @@ -25,6 +25,8 @@ import Bilge import Bilge.Assert import Control.Lens import Control.Monad.Random +import qualified Data.Aeson as Aeson +import Data.Aeson.Lens (key, _String) import Data.ByteString.Conversion import qualified Data.ByteString.Lazy as Lazy import Data.Handle (Handle, parseHandle) @@ -62,6 +64,7 @@ import qualified Web.Scim.Schema.User as Scim import qualified Web.Scim.Schema.User as Scim.User import qualified Web.Scim.Schema.User.Email as Scim.Email import qualified Web.Scim.Schema.User.Phone as Phone +import qualified Web.Scim.Schema.User.Role as Scim.Role import qualified Wire.API.Team.Member as Member import Wire.API.Team.Role (Role, defaultRole) import Wire.API.User @@ -69,6 +72,14 @@ import Wire.API.User.IdentityProvider hiding (handle, team) import Wire.API.User.RichInfo import Wire.API.User.Scim +-- | Build a SCIM @roles@ list (RFC 7643 complex form) from plain role-name strings. +mkScimRoles :: [Text] -> [Scim.Role.Role] +mkScimRoles = map (\v -> Scim.Role.Role (Just v) Nothing Nothing Nothing) + +-- | Extract the role-name strings from a SCIM @roles@ list. +scimRoleValues :: [Scim.Role.Role] -> [Text] +scimRoleValues = mapMaybe Scim.Role.value + -- | Take apart a 'ValidScimId', using 'SAML.UserRef' if available, otherwise 'Email'. runValidScimIdEither :: (SAML.UserRef -> a) -> (EmailAddress -> a) -> ValidScimId -> a runValidScimIdEither doUref doEmail = these doEmail doUref (\_ uref -> doUref uref) . validScimIdAuthInfo @@ -162,7 +173,7 @@ randomScimUserWithSubjectAndRichInfo richInfo = do Scim.User.externalId = Just externalId, Scim.User.emails = [], Scim.User.phoneNumbers = phones, - Scim.User.roles = ["member"] + Scim.User.roles = mkScimRoles ["member"] -- if we don't add this role here explicitly, some tests may show confusing failures -- involving [] or null being changed to ["member"] during a create or update -- operation. @@ -724,7 +735,7 @@ setDefaultRoleAndEmailsIfEmpty :: Scim.User.User a -> Scim.User.User a setDefaultRoleAndEmailsIfEmpty u = u { Scim.User.roles = case Scim.User.roles u of - [] -> [cs $ toByteString' defaultRole] + [] -> mkScimRoles [cs $ toByteString' defaultRole] xs -> xs, -- when the emails field is empty, we try to populate it with the externalId Scim.User.emails = case Scim.User.emails u of @@ -757,3 +768,35 @@ checkTeamMembersRole :: (HasCallStack) => TeamId -> UserId -> UserId -> Role -> checkTeamMembersRole tid owner uid role = do [member] <- filter ((== uid) . (^. Member.userId)) <$> getTeamMembers owner tid liftIO $ (member ^. Member.permissions . to Member.permissionsRole) `shouldBe` Just role + +-- | Create the body of a scim error response (rfc7644, section 3.12). The arguments are the +-- @detail@, @scimType@ and @status@ fields; @schemas@ is the same for every scim error and is +-- filled in here. +-- +-- Both 'Maybe' arguments mean different things when they are 'Nothing': +-- +-- * @detail@: the message is /not tested/. Whatever the response contains is echoed into the +-- expected value, so this field can never make the comparison fail. +-- +-- * @scimType@: the key must be /absent/. That is what the server does when there is no error +-- type, as opposed to rendering it as @null@. +-- +-- > deleteUser_ Nothing (Just uid) spar !!! do +-- > const 401 === statusCode +-- > mkScimErrorResp Nothing Nothing "401" === responseBody +mkScimErrorResp :: + Maybe Text -> + Maybe Text -> + Text -> + Response (Maybe LByteString) -> + Maybe LByteString +mkScimErrorResp mDetail scimType status resp = + Just . Aeson.encode . Aeson.object . catMaybes $ + [ ("detail" Aeson..=) <$> (mDetail <|> actualDetail), + Just ("schemas" Aeson..= ["urn:ietf:params:scim:api:messages:2.0:Error" :: Text]), + ("scimType" Aeson..=) <$> scimType, + Just ("status" Aeson..= status) + ] + where + actualDetail :: Maybe Text + actualDetail = responseBody resp >>= (^? key "detail" . _String) diff --git a/services/spar/test/Arbitrary.hs b/services/spar/test/Arbitrary.hs index 65a02e8bb07..82089277c58 100644 --- a/services/spar/test/Arbitrary.hs +++ b/services/spar/test/Arbitrary.hs @@ -33,7 +33,6 @@ import SAML2.WebSSO.Test.Arbitrary () import SAML2.WebSSO.Types import Servant.API.ContentTypes import Spar.Scim -import Spar.Scim.Types (ScimUserCreationStatus) import Test.QuickCheck import URI.ByteString import Wire.API.User.IdentityProvider diff --git a/services/spar/test/Test/Spar/ErrorSpec.hs b/services/spar/test/Test/Spar/ErrorSpec.hs new file mode 100644 index 00000000000..ebfba25b443 --- /dev/null +++ b/services/spar/test/Test/Spar/ErrorSpec.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Spar.ErrorSpec where + +import Data.Aeson (eitherDecode') +import Data.Aeson.QQ (aesonQQ) +import Imports +import qualified SAML2.WebSSO as SAML +import Servant (ServerError (..)) +import Spar.Error +import Test.Hspec +import qualified Web.Scim.Schema.Error as Scim + +spec :: Spec +spec = describe "sparToServerError" $ do + -- RFC 7644 section 3.12 requires that the response body of a SCIM error *is* + -- the SCIM error object, not a wire-server 'Wai.Error' with the SCIM error + -- object nested (double-encoded) into its 'message' field. + it "renders a SCIM error as the bare RFC 7644 error object" $ do + let scimErr = + Scim.badRequest + Scim.InvalidValue + (Just "Could not process externalId.") + serverErr = sparToServerError (SAML.CustomError (SparScimError scimErr)) + eitherDecode' (errBody serverErr) + `shouldBe` Right + [aesonQQ| + { + "detail": "Could not process externalId.", + "schemas": [ + "urn:ietf:params:scim:api:messages:2.0:Error" + ], + "scimType": "invalidValue", + "status": "400" + }|] + errHTTPCode serverErr `shouldBe` 400 + lookup "Content-Type" (errHeaders serverErr) + `shouldBe` Just "application/scim+json;charset=utf-8" diff --git a/services/spar/test/Test/Spar/Saml/IdPSpec.hs b/services/spar/test/Test/Spar/Saml/IdPSpec.hs index f287590df98..87892f11f77 100644 --- a/services/spar/test/Test/Spar/Saml/IdPSpec.hs +++ b/services/spar/test/Test/Spar/Saml/IdPSpec.hs @@ -1,3 +1,20 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Spar.Saml.IdPSpec where import Arbitrary () @@ -34,18 +51,12 @@ import Spar.Options (CertFingerprintAllowlist (CertFingerprintAllowlist)) import qualified Spar.Options import Spar.Sem.AReqIDStore (AReqIDStore (..)) import Spar.Sem.AssIDStore (AssIDStore (..)) -import Spar.Sem.IdPRawMetadataStore -import Spar.Sem.IdPRawMetadataStore.Mem -import Spar.Sem.Reporter (Reporter (..)) import Spar.Sem.SAML2 (SAML2 (..)) import Spar.Sem.SAMLUserStore import qualified Spar.Sem.SAMLUserStore as SAMLUserStore import Spar.Sem.SAMLUserStore.Mem -import Spar.Sem.SamlProtocolSettings (SamlProtocolSettings) -import Spar.Sem.SamlProtocolSettings.Servant (sparRouteToServant) import Spar.Sem.ScimTokenStore import Spar.Sem.ScimTokenStore.Mem -import qualified Spar.Sem.VerdictFormatStore as VerdictFormatStore import System.FilePath (()) import System.Logger (Msg) import System.Logger.Class (Level (..)) @@ -69,10 +80,18 @@ import Wire.GalleyAPIAccess (GalleyAPIAccess) import qualified Wire.GalleyAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem +import Wire.IdPRawMetadataStore +import Wire.IdPRawMetadataStore.Mem +import Wire.Reporter (Reporter (..)) +import Wire.SamlProtocolSettings (SamlProtocolSettings) +import Wire.SamlProtocolSettings.Servant (sparRouteToServant) +import Wire.ScimExternalIdStore (ScimExternalIdStore) +import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) import Wire.Sem.Logger (discardLogs) import Wire.Sem.Logger.TinyLog (LogRecorder (..), newLogRecorder, recordLogs) import Wire.Sem.Random import Wire.Sem.Random.Null +import qualified Wire.VerdictFormatStore as VerdictFormatStore spec :: Spec spec = @@ -94,13 +113,13 @@ spec = _cfgSPPort = 8081, _cfgDomainConfigs = Left anyMultiIngressDomainCfg } - host = Just "backend.example.com" + host = either (error . show) Just $ mkDomain "backend.example.com" miHost1AsText = "backend-1.example.com" miDomain1 = either (error . show) id $ mkDomain miHost1AsText - miHost1 = Just miHost1AsText + miHost1 = Just miDomain1 miHost2AsText = "backend-2.example.com" miDomain2 = either (error . show) id $ mkDomain miHost2AsText - miHost2 = Just miHost2AsText + miHost2 = Just miDomain2 multiIngressSamlConfig = Config { -- The log level only matters for log output, not production. @@ -526,8 +545,8 @@ spec = notifs `shouldBe` mempty describe "IdP cert fingerprint allowlist" $ do - let withAllow :: Maybe Spar.Options.CertFingerprintAllowlist -> Spar.Options.Opts - withAllow allow = defaultTestOpts {Spar.Options.idpCertFingerprintAllowlist = allow} + let withAllow :: Spar.Options.Opts -> Maybe Spar.Options.CertFingerprintAllowlist -> Spar.Options.Opts + withAllow opts allow = opts {Spar.Options.idpCertFingerprintAllowlist = allow} generateArbitraryIdPInfo :: IO IdPMetadataInfo generateArbitraryIdPInfo = do @@ -587,32 +606,32 @@ spec = describe "create" $ do it "accepts any cert when allowlist is Nothing" $ do idpInfo <- generateArbitraryIdPInfo - (_logs, res) <- runCreate (withAllow Nothing) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` Nothing) idpInfo res `shouldSatisfy` isRight it "accepts any cert when allowlist is empty" $ do idpInfo <- generateArbitraryIdPInfo let empty = Spar.Options.CertFingerprintAllowlist Set.empty - (_logs, res) <- runCreate (withAllow (Just empty)) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` (Just empty)) idpInfo res `shouldSatisfy` isRight it "accepts when all fingerprints are allowlisted" $ do idpInfo <- generateArbitraryIdPInfo let allow = allCertsAllowlist idpInfo - (_logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldSatisfy` isRight it "accepts when all multi-cert fingerprints are allowlisted" $ do idpInfo <- generateTwoCertIdPInfo let allow = allCertsAllowlist idpInfo - (_logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (_logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldSatisfy` isRight it "rejects when no fingerprint matches and logs the refusal" $ do idpInfo@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo let allow = singletonAllowlist bogusFingerprint fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse - (logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) logged `shouldSatisfy` (("cert fingerprint not in allowlist, fingerprint=" <> fingerprint) `TL.isInfixOf`) @@ -621,50 +640,85 @@ spec = idpInfo@(IdPMetadataValue _ m) <- generateTwoCertIdPInfo let allow = singletonAllowlist (firstCertFingerprint idpInfo) secondCertFingerprint = certToFingerprint . head . NonEmptyL.tail $ m._edCertAuthnResponse - (logs, res) <- runCreate (withAllow (Just allow)) idpInfo + (logs, res) <- runCreate (defaultTestOpts `withAllow` (Just allow)) idpInfo res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed secondCertFingerprint)) let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) logged `shouldSatisfy` (("cert fingerprint not in allowlist, fingerprint=" <> secondCertFingerprint) `TL.isInfixOf`) + describe "multi-ingress" $ do + it "accepts when all fingerprints are allowlisted" $ do + idpInfo <- generateArbitraryIdPInfo + let allow = allCertsAllowlist idpInfo + optsWithMultiIngress = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = Just allow + } + (_logs, _notifs, res) <- + interpretWithLoggingMockOptsE optsWithMultiIngress Nothing $ + idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfo Nothing apiVersionV2 idpHandle + res `shouldSatisfy` isRight + + let testRejectEmptyAllowlist testName allowlist = + it testName $ do + idpInfo@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo + let optsWithMultiIngress = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = allowlist + } + fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse + (logs, _notifs, res) <- + interpretWithLoggingMockOptsE optsWithMultiIngress Nothing $ + idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfo Nothing apiVersionV2 idpHandle + res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) + let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) + logged `shouldSatisfy` (("Refusing IdP request: multi-ingress enabled and allowlist empty" :: TL.Text) `TL.isInfixOf`) + + testRejectEmptyAllowlist "rejects when allowlist is Nothing" Nothing + testRejectEmptyAllowlist + "rejects when allowlist is empty" + (Just (Spar.Options.CertFingerprintAllowlist Set.empty)) + describe "update" $ do it "accepts any cert when allowlist is Nothing" $ do idpInfoCrt <- generateArbitraryIdPInfo idpInfoUpd <- generateArbitraryIdPInfo - (_logs, res) <- runCreateUpdate (withAllow Nothing) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` Nothing) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "accepts any cert when allowlist is empty" $ do idpInfoCrt <- generateArbitraryIdPInfo idpInfoUpd <- generateArbitraryIdPInfo - (_logs, res) <- runCreateUpdate (withAllow (Just mempty)) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` (Just mempty)) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "accepts when all fingerprints are allowlisted" $ do idpInfoCrt <- generateArbitraryIdPInfo idpInfoUpd <- generateArbitraryIdPInfo let allow = allCertsAllowlist idpInfoCrt <> allCertsAllowlist idpInfoUpd - (_logs, res) <- runCreateUpdate (withAllow (Just allow)) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` (Just allow)) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "accepts when all multi-cert fingerprints are allowlisted" $ do idpInfoCrt <- generateTwoCertIdPInfo idpInfoUpd <- generateTwoCertIdPInfo let allow = allCertsAllowlist idpInfoCrt <> allCertsAllowlist idpInfoUpd - (_logs, res) <- runCreateUpdate (withAllow (Just allow)) idpInfoCrt idpInfoUpd + (_logs, res) <- runCreateUpdate (defaultTestOpts `withAllow` (Just allow)) idpInfoCrt idpInfoUpd res `shouldSatisfy` isRight it "rejects when no fingerprint matches" $ do idpInfo@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo let fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse (_logs1, _notifs1, createdE) <- - interpretWithLoggingMockOptsE (withAllow Nothing) Nothing $ + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` Nothing) Nothing $ idpCreate singleIngressSamlConfig tid zUser host idpInfo Nothing apiVersionV2 idpHandle case createdE of Left e -> expectationFailure ("unexpected create failure: " <> show e) Right idp -> do let allow = singletonAllowlist bogusFingerprint (_logs2, _notifs2, res) <- - interpretWithLoggingMockOptsE (withAllow (Just allow)) Nothing $ do + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` (Just allow)) Nothing $ do insertConfig idp idpUpdate singleIngressSamlConfig zUser host idpInfo (idp._idpId) Nothing res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) @@ -674,17 +728,69 @@ spec = let partialAllow = singletonAllowlist (firstCertFingerprint idpInfo) secondCertFingerprint = certToFingerprint . head . NonEmptyL.tail $ m._edCertAuthnResponse (_logs1, _notifs1, createdE) <- - interpretWithLoggingMockOptsE (withAllow Nothing) Nothing $ + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` Nothing) Nothing $ idpCreate singleIngressSamlConfig tid zUser host idpInfo Nothing apiVersionV2 idpHandle case createdE of Left e -> expectationFailure ("unexpected create failure: " <> show e) Right idp -> do (_logs2, _notifs2, res) <- - interpretWithLoggingMockOptsE (withAllow (Just partialAllow)) Nothing $ do + interpretWithLoggingMockOptsE (defaultTestOpts `withAllow` (Just partialAllow)) Nothing $ do insertConfig idp idpUpdate singleIngressSamlConfig zUser host idpInfo (idp._idpId) Nothing res `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed secondCertFingerprint)) + describe "multi-ingress" $ do + it "accepts when all fingerprints are allowlisted" $ do + idpInfoCrt <- generateArbitraryIdPInfo + idpInfoUpd <- generateArbitraryIdPInfo + let allow = allCertsAllowlist idpInfoCrt <> allCertsAllowlist idpInfoUpd + optsWithMultiIngress = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = Just allow + } + (_logs, _notifs, res) <- + interpretWithLoggingMockOptsE optsWithMultiIngress Nothing $ do + idp <- idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfoCrt Nothing apiVersionV2 idpHandle + idpUpdate multiIngressSamlConfig zUser miHost1 idpInfoUpd (idp._idpId) Nothing + res `shouldSatisfy` isRight + + let testRejectEmptyAllowlistOnUpdate testName updateAllowlist = + it testName $ do + idpInfoCrt <- generateArbitraryIdPInfo + idpInfoUpd@(IdPMetadataValue _ m) <- generateArbitraryIdPInfo + let allowCrt = allCertsAllowlist idpInfoCrt + fingerprint = certToFingerprint . NonEmptyL.head $ m._edCertAuthnResponse + optsForCreate = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = Just allowCrt + } + optsForUpdate = + defaultTestOpts + { Spar.Options.saml = multiIngressSamlConfig, + Spar.Options.idpCertFingerprintAllowlist = updateAllowlist + } + (_logsCrt, _notifsCrt, createE) <- + interpretWithLoggingMockOptsE optsForCreate Nothing $ + idpCreate multiIngressSamlConfig tid zUser miHost1 idpInfoCrt Nothing apiVersionV2 idpHandle + case createE of + Left e -> expectationFailure ("unexpected create failure: " <> show e) + Right idp -> do + (logsUpd, _notifsUpd, updateE) <- + interpretWithLoggingMockOptsE optsForUpdate Nothing $ do + insertConfig idp + idpUpdate multiIngressSamlConfig zUser miHost1 idpInfoUpd (idp._idpId) Nothing + updateE `shouldBe` Left (SAML.CustomError (SparIdPCertNotAllowed fingerprint)) + let logged = TL.decodeUtf8 $ LBS.concat (map snd logsUpd) + logged `shouldSatisfy` (("Refusing IdP request: multi-ingress enabled and allowlist empty" :: TL.Text) `TL.isInfixOf`) + + testRejectEmptyAllowlistOnUpdate "rejects when allowlist is Nothing" Nothing + + testRejectEmptyAllowlistOnUpdate + "rejects when allowlist is empty" + (Just (Spar.Options.CertFingerprintAllowlist Set.empty)) + describe "authresp" $ do let makeIdp :: IdPMetadataInfo -> IO IdP makeIdp (IdPMetadataValue _ metadata) = do @@ -720,7 +826,7 @@ spec = raw <- generate (arbitrary :: Gen (MultipartData Mem)) let dummyBody = either error id (fromMultipart raw :: Either String SAML.AuthnResponseBody) user <- generate arbitrary - let opts = withAllow allow + let opts = defaultTestOpts `withAllow` allow uref = SAML.UserRef m._edIssuer (SAML.unspecifiedNameID "test-user") user' = user {userTeam = Just authrspTestTeamId} verdict = SAML.AccessGranted uref @@ -800,6 +906,53 @@ spec = logged `shouldNotSatisfy` ("cert fingerprint not in allowlist" `TL.isInfixOf`) other -> expectationFailure $ "expected CustomServant (VerifyHandlerGranted), got: " <> show other + describe "multi-ingress" $ do + let makeAuthRespRequestMultiIngress teamIdParam idpInfo@(IdPMetadataValue _ m) allow = do + idp <- makeIdp idpInfo + ass <- makeControlledAssertion + raw <- generate (arbitrary :: Gen (MultipartData Mem)) + let dummyBody = either error id (fromMultipart raw :: Either String SAML.AuthnResponseBody) + user <- generate arbitrary + let opts = + (defaultTestOpts `withAllow` allow) + { Spar.Options.saml = multiIngressSamlConfig + } + uref = SAML.UserRef m._edIssuer (SAML.unspecifiedNameID "test-user") + user' = user {userTeam = Just authrspTestTeamId} + verdict = SAML.AccessGranted uref + interpretAuthrespE opts (Just user') (ass NonEmptyL.:| [], idp, verdict) $ do + SAMLUserStore.insert uref (userId user') + authresp teamIdParam dummyBody (Just miDomain1) + + it ("accepts when all fingerprints are allowlisted - teamId param " <> show requestParamTeamId) $ do + idpInfo <- generateArbitraryIdPInfo + let allow = allCertsAllowlist idpInfo + (logs, res) <- makeAuthRespRequestMultiIngress requestParamTeamId idpInfo $ Just allow + case res of + Left (SAML.CustomServant servantErr) -> do + errHTTPCode servantErr `shouldBe` 200 + errReasonPhrase servantErr `shouldBe` "success" + let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) + logged `shouldNotSatisfy` ("cert fingerprint not in allowlist" `TL.isInfixOf`) + other -> expectationFailure $ "expected success, got: " <> show other + + let testRejectEmptyAllowlistAuthresp testName allowlist = + it (testName <> " - teamId param " <> show requestParamTeamId) $ do + idpInfo <- generateArbitraryIdPInfo + (logs, res) <- makeAuthRespRequestMultiIngress requestParamTeamId idpInfo allowlist + case res of + Left (SAML.CustomServant servantErr) -> do + errHTTPCode servantErr `shouldBe` 403 + errReasonPhrase servantErr `shouldBe` "idp-cert-not-allowed" + let logged = TL.decodeUtf8 $ LBS.concat (map snd logs) + logged `shouldSatisfy` (("Refusing IdP request: multi-ingress enabled and allowlist empty" :: TL.Text) `TL.isInfixOf`) + other -> expectationFailure $ "expected idp-cert-not-allowed error, got: " <> show other + + testRejectEmptyAllowlistAuthresp "rejects when allowlist is Nothing" Nothing + testRejectEmptyAllowlistAuthresp + "rejects when allowlist is empty" + (Just (Spar.Options.CertFingerprintAllowlist Set.empty)) + type LogLine = (Level, LByteString) interpretWithLoggingMock :: @@ -941,6 +1094,7 @@ type AuthrespEffs = GalleyAPIAccess, BrigAPIAccess, ScimTokenStore, + ScimExternalIdStore, IdPConfigStore, IdPRawMetadataStore, Logger (Msg -> Msg), @@ -989,6 +1143,7 @@ interpretAuthrespE opts mbAccount triplet action = do . recordLogs lr . ignoringState idpRawMetadataStoreToMem . ignoringState idPToMem + . ignoringState scimExternalIdStoreToMem . ignoringState scimTokenStoreToMem . brigAccessMock mbAccount . galleyAccessMock diff --git a/services/spar/test/Test/Spar/Scim/UserSpec.hs b/services/spar/test/Test/Spar/Scim/UserSpec.hs index 5088a8fde7d..c7c9678d906 100644 --- a/services/spar/test/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test/Test/Spar/Scim/UserSpec.hs @@ -28,10 +28,6 @@ import Polysemy.TinyLog import Spar.Scim.User (deleteScimUser) import Spar.Sem.SAMLUserStore import Spar.Sem.SAMLUserStore.Mem (samlUserStoreToMem) -import qualified Spar.Sem.ScimExternalIdStore as ScimExternalIdStore -import Spar.Sem.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) -import Spar.Sem.ScimUserTimesStore -import Spar.Sem.ScimUserTimesStore.Mem (scimUserTimesStoreToMem) import System.Logger (Msg) import Test.Hspec import Test.QuickCheck @@ -42,6 +38,10 @@ import Wire.BrigAPIAccess import Wire.IdPConfigStore import Wire.IdPConfigStore.Mem (idPToMem) import Wire.IdPConfigStore.Orphans () +import qualified Wire.ScimExternalIdStore as ScimExternalIdStore +import Wire.ScimExternalIdStore.Mem (scimExternalIdStoreToMem) +import Wire.ScimUserMetaStore +import Wire.ScimUserMetaStore.Mem (scimUserMetaStoreToMem) import Wire.Sem.Logger.TinyLog (discardTinyLogs) spec :: Spec @@ -85,7 +85,7 @@ deleteUserAndAssertDeletionInSpar :: '[ Logger (Msg -> Msg), BrigAPIAccess, ScimExternalIdStore.ScimExternalIdStore, - ScimUserTimesStore, + ScimUserMetaStore, SAMLUserStore, IdPConfigStore, Embed IO @@ -108,7 +108,7 @@ deleteUserAndAssertDeletionInSpar acc tokenInfo = do type EffsWithoutBrigAPIAccess = '[ IdPConfigStore, SAMLUserStore, - ScimUserTimesStore, + ScimUserMetaStore, ScimExternalIdStore.ScimExternalIdStore, Logger (Msg -> Msg), Embed IO, @@ -126,7 +126,7 @@ interpretWithBrigAPIAccessMock mock = . embedToFinal @IO . discardTinyLogs . ignoringState scimExternalIdStoreToMem - . ignoringState scimUserTimesStoreToMem + . ignoringState scimUserMetaStoreToMem . ignoringState samlUserStoreToMem . ignoringState idPToMem . mock diff --git a/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs b/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs index 32e703901f8..12767810e6f 100644 --- a/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs +++ b/services/spar/test/Test/Spar/Sem/DefaultSsoCodeSpec.hs @@ -24,10 +24,10 @@ module Test.Spar.Sem.DefaultSsoCodeSpec where import Arbitrary () import Imports import Polysemy -import Spar.Sem.DefaultSsoCode.Mem import Spar.Sem.DefaultSsoCode.Spec import Test.Hspec import Test.Hspec.QuickCheck +import Wire.DefaultSsoStore.Mem (defaultSsoCodeToMem) spec :: Spec spec = modifyMaxSuccess (const 1000) $ do diff --git a/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs b/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs index 36abf4ea039..a85921cdb57 100644 --- a/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs +++ b/services/spar/test/Test/Spar/Sem/IdPRawMetadataStoreSpec.hs @@ -22,11 +22,11 @@ module Test.Spar.Sem.IdPRawMetadataStoreSpec where import Arbitrary () import Imports import Polysemy -import qualified Spar.Sem.IdPRawMetadataStore as E -import Spar.Sem.IdPRawMetadataStore.Mem import Spar.Sem.IdPRawMetadataStore.Spec import Test.Hspec import Test.Hspec.QuickCheck +import qualified Wire.IdPRawMetadataStore as E +import Wire.IdPRawMetadataStore.Mem testInterpreter :: Sem '[E.IdPRawMetadataStore] a -> IO (RawState, a) testInterpreter = pure . run . idpRawMetadataStoreToMem diff --git a/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs b/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs index af78e5b7245..ed48fed68ce 100644 --- a/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs +++ b/services/spar/test/Test/Spar/Sem/SamlProtocolSettingsSpec.hs @@ -27,11 +27,11 @@ import qualified Data.Text.Encoding as T import Imports import Polysemy import SAML2.WebSSO -import Spar.Sem.SamlProtocolSettings -import Spar.Sem.SamlProtocolSettings.Servant (sparRouteToServant) import Test.Hspec import Test.Hspec.QuickCheck import URI.ByteString (aggressiveNormalization, normalizeURIRef) +import Wire.SamlProtocolSettings +import Wire.SamlProtocolSettings.Servant (sparRouteToServant) spec :: Spec spec = do diff --git a/tools/db/migrate-features/src/Work.hs b/tools/db/migrate-features/src/Work.hs index 8aef63d20dd..60a1f8061d4 100644 --- a/tools/db/migrate-features/src/Work.hs +++ b/tools/db/migrate-features/src/Work.hs @@ -337,6 +337,7 @@ writeFeatures ( MlsMigrationConfig @Covered (fmap unOptionalUTCTime mls_migration_start_time) (fmap unOptionalUTCTime mls_migration_finalise_regardless_after) + def -- allowManualMigration was added recently, so there's nothing to migrate ) } diff --git a/tools/sftd_disco/Dockerfile b/tools/sftd_disco/Dockerfile deleted file mode 100644 index 011aac9c6a1..00000000000 --- a/tools/sftd_disco/Dockerfile +++ /dev/null @@ -1,7 +0,0 @@ -FROM alpine:3.21.3 - -RUN apk add --no-cache curl bash openssl bind-tools jq - -COPY sftd_disco.sh /usr/bin/sftd_disco.sh - -ENTRYPOINT ["/usr/bin/sftd_disco.sh"] diff --git a/tools/sftd_disco/Makefile b/tools/sftd_disco/Makefile deleted file mode 100644 index 45e71e806c7..00000000000 --- a/tools/sftd_disco/Makefile +++ /dev/null @@ -1,6 +0,0 @@ -.PHONY: docker - -DOCKER_TAG = 1.1.1 - -docker: - docker build -t quay.io/wire/sftd_disco:$(DOCKER_TAG) -f Dockerfile . diff --git a/tools/sftd_disco/README.md b/tools/sftd_disco/README.md deleted file mode 100644 index e78f7679ebe..00000000000 --- a/tools/sftd_disco/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# sftd-disco - -This DISCOvery docker image/bash script converts the result from an SRV DNS lookup of a kubernetes service to a file which can be served by nginx or similar. - -This is useful as a sidecar container to the sftd chart in kubernetes to expose the full list of running sftd servers in cases where sftd runs independently from other backend services. See also [the sftd helm chart](https://github.com/wireapp/wire-server/tree/develop/charts/sftd/) diff --git a/tools/sftd_disco/sftd_disco.sh b/tools/sftd_disco/sftd_disco.sh deleted file mode 100755 index 2cf660cdb3a..00000000000 --- a/tools/sftd_disco/sftd_disco.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash - -set -eo pipefail -exec 2>&1 - -# Assumes /etc/wire/sftd-disco/ directory exists. - -USAGE="example usage: $0 _sft._tcp.wire-server-sftd.wire.svc.cluster.local" -srv_name=${1?$USAGE} - -old="/etc/wire/sftd-disco/sft_servers_all.json" -new="${old}.new" - -function valid_entry() { - # TODO sanity check that this is real dig output - return 0 -} - -function valid_url() { - #TODO basic sanity check - return 0 -} - -# for a given SRV record -# 1. lookup the record -# 2. for each entry: extract host and port and call 'curl host:port/sft/url' -# 4. save the resulting URLs as a json array to a file -# this file can then be served from nginx running besides sft -function upstream() { - local srv_name="$1" - entries=$(dig +short +retries=3 +search SRV "${srv_name}" | sort) - unset servers - comma="" - IFS=$'\t\n' - for entry in $entries; do - if valid_entry "$entry"; then - sft_host_port=$(echo "$entry" | awk '{print $4":"$3}') - sft_url=$(curl -s http://"$sft_host_port"/sft/url | xargs) - if valid_url "$sft_url"; then - servers+=("$comma"'"'"$sft_url"'"') - comma="," - fi - fi - done - # shellcheck disable=SC2128 - if [[ -n "$servers" ]]; then - echo '{"sft_servers_all": ['"${servers[*]}"']}' | jq >${new} - else - printf "" >>${new} - fi -} - -function routing_disco() { - local srv_name="$1" - ivl=$(echo | awk '{ srand(); printf("%f", 2.5 + rand() * 1.5) }') - - [[ -f $old ]] || touch -d "1970-01-01" $old - - echo "" >${new} - upstream "$srv_name" - - diff -q $old $new || { - echo upstream change found, replacing $old with $new - mv $new $old - } - - rm -f $new - - echo done, sleeping "$ivl" - sleep "$ivl" - return 0 -} - -while true; do - routing_disco "$srv_name" -done diff --git a/tools/stern/src/Stern/API.hs b/tools/stern/src/Stern/API.hs index 45066bbc5ff..610601db124 100644 --- a/tools/stern/src/Stern/API.hs +++ b/tools/stern/src/Stern/API.hs @@ -3,6 +3,7 @@ {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeOperators #-} {-# LANGUAGE ViewPatterns #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-orphans #-} -- This file is part of the Wire Server implementation. diff --git a/tools/stern/src/Stern/API/Routes.hs b/tools/stern/src/Stern/API/Routes.hs index a216a2bb671..ba212cc81a9 100644 --- a/tools/stern/src/Stern/API/Routes.hs +++ b/tools/stern/src/Stern/API/Routes.hs @@ -14,6 +14,24 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# OPTIONS_GHC -Wno-deprecations #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2026 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . module Stern.API.Routes ( SternAPI, diff --git a/tools/stern/test/integration/API.hs b/tools/stern/test/integration/API.hs index f90a8a6441a..b0b30b5afb3 100644 --- a/tools/stern/test/integration/API.hs +++ b/tools/stern/test/integration/API.hs @@ -1,5 +1,6 @@ {-# LANGUAGE OverloadedRecordDot #-} {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-deprecations #-} {-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-} diff --git a/treefmt.toml b/treefmt.toml index 949295a0b4c..516cbd86fbb 100644 --- a/treefmt.toml +++ b/treefmt.toml @@ -10,6 +10,14 @@ excludes = [ "dist-newstyle/" ] +[formatter.headroom] +command = "hack/bin/headroom-treefmt.sh" +includes = ["*.hs", "*.hsc", "*.rs"] +excludes = [ + "dist-newstyle/", + "services/wire-server-enterprise/*", +] + [formatter.shellcheck] command = "shellcheck" options = ["-x"] diff --git a/weeder.toml b/weeder.toml index a738c077e87..f9e5ce6495c 100644 --- a/weeder.toml +++ b/weeder.toml @@ -137,7 +137,7 @@ roots = [ # may of the entries here are about general-purpose module "^Spar.Sem.AReqIDStore.Mem.*$", # FUTUREWORK: @fisx can we delete this? "^Spar.Sem.AssIDStore.Mem.*$", # FUTUREWORK: @fisx can we delete this? "^Spar.Sem.ScimTokenStore.Mem.*$", # FUTUREWORK: @fisx can we delete this? - "^Spar.Sem.VerdictFormatStore.Mem.*$", # FUTUREWORK: @fisx can we delete this? + "^Wire.VerdictFormatStore.Mem.*$", # FUTUREWORK: @fisx can we delete this? "^Spec.main$", "^Stern.App.runHandler$", "^System.Logger.Extended.runWithLogger$",