Compare commits

..

22 Commits

Author SHA1 Message Date
Thomas Ramé 70e021965a wip 2026-04-28 11:54:37 +02:00
Thomas Ramé deea80864a wip entry 2026-04-08 15:47:09 +02:00
Thomas Ramé 8fb4954c7d wip 2026-04-08 15:37:44 +02:00
Thomas Ramé b60193065c wip 2026-04-08 14:29:45 +02:00
Thomas Ramé 6410665c5d wip tests 2026-04-07 17:52:40 +02:00
Thomas Ramé b1be05dddb wip fu 2026-04-07 17:50:46 +02:00
Thomas Ramé ef8eda66d6 wip tests 2026-04-07 17:50:32 +02:00
Thomas Ramé 32ecc3836e wip enhancements 2026-04-07 17:46:40 +02:00
Thomas Ramé cddd9e3cd5 wip improvement 2026-04-07 16:37:13 +02:00
Thomas Ramé 9abb560d3c wip working with vaultclient 2026-04-07 16:29:47 +02:00
Thomas Ramé 0c21978ca4 working with libsodium (issue from RTC due to encrypted headers in packets?) 2026-04-07 16:29:47 +02:00
Thomas Ramé 7105c6bc70 working with livekit settings 2026-04-07 16:29:47 +02:00
Thomas Ramé 267b39a975 wip issue 2026-04-07 16:29:47 +02:00
Thomas Ramé d7ce25b1b5 wip advanced 2026-04-02 18:52:11 +02:00
Thomas Ramé 316008016c wip working with hash 2026-04-02 15:54:11 +02:00
Thomas Ramé 191adc0499 make "dcEncryptionEnabled" true to encrypt chats 2026-03-31 17:28:58 +02:00
Thomas Ramé 854792e2ef wip 2026-03-31 16:03:35 +02:00
Thomas Ramé 547e93b9f1 switch to exchanging through visio backend instead of livekit backend 2026-03-31 15:39:26 +02:00
Thomas Ramé d919cd8097 tmp broken 2026-03-31 14:42:24 +02:00
Thomas Ramé 768b5d7680 wip 2026-03-31 14:34:26 +02:00
Thomas Ramé 5376d39111 good but need to investigate better exchange 2026-03-31 13:11:18 +02:00
Thomas Ramé 58bc6398eb wip base 2026-03-30 17:37:05 +02:00
194 changed files with 7357 additions and 5150 deletions
+5 -2
View File
@@ -223,6 +223,8 @@ jobs:
DB_PORT: 5432 DB_PORT: 5432
REDIS_URL: redis://localhost:6379/1 REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000 AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password AWS_S3_SECRET_ACCESS_KEY: password
@@ -304,8 +306,7 @@ jobs:
working-directory: src/summary working-directory: src/summary
env: env:
V1_TENANT_ID: 'test-tenant' APP_API_TOKEN: "test-api-token"
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage" AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000" AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet" AWS_S3_ACCESS_KEY_ID: "meet"
@@ -317,6 +318,8 @@ jobs:
LLM_BASE_URL: "https://configure-your-url.com" LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret" LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model" LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps: steps:
- name: Checkout repository - name: Checkout repository
-35
View File
@@ -8,44 +8,9 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
## [1.14.0] - 2026-04-16
### Added
- 🔒️(helm) Add pod and container securityContext #1197
- ✨(summary) add routes v2 for async STT and summary tasks #1171
- ✅(backend) add unit tests for JwtTokenService #1232
### Changed
- ⬆️(backend) bump lodash from 4.17.23 to 4.18.1 in /src/mail
- ⬆️(frontend) bump hono from 4.12.8 to 4.12.12 in /src/frontend
- ⬆️(backend) bump pygments from 2.19.2 to 2.20.0 in /src/backend
- ♻️(backend) use Authorization header for LiveKit token authentication
- 🥅(backend) refine Twirp error handling for participant operations
- ✨(summary) allow more file extensions #1265
- ♿️(frontend) refocus reactions toolbar with ctrl+shift+e is activated #1262
- ♿️(frontend) set an explicit document title on recording download page #1261
### Fixed
- ⬆️(dependencies) update aiohttp to v3.13.4 [SECURITY]
- ⬆️(dependencies) update vite to v7.3.2 [SECURITY]
- ⬆️(dependencies) update django to v5.2.13 [SECURITY]
- 🔒(backend) rely on backend to allow participant update their metadata
- 🐛(summary) fix failure webhook notification #1233
- 🐛(summary) relax whisperX payload format #1233
- ⬆️(backend) upgrade dependencies to fix Pillow CVE-2026-40192
- ⬆️(frontend) upgrade frontend image to Alpine 3.23 to address CVEs
## [1.13.0] - 2026-03-31
### Changed ### Changed
- ⬆️(dependencies) update python dependencies - ⬆️(dependencies) update python dependencies
- ♿️(frontend) add explicit region for call controls #1216
- ♿️(frontend) improve accessibility of the reaction toolbar #1216
- ♿️(frontend) enhance sidepanel navigation accessibility #1216
### Fixed ### Fixed
+10 -22
View File
@@ -73,9 +73,7 @@ create-env-files: \
env.d/development/crowdin \ env.d/development/crowdin \
env.d/development/postgresql \ env.d/development/postgresql \
env.d/development/kc_postgresql \ env.d/development/kc_postgresql \
env.d/development/summary \ env.d/development/summary
env.d/development/kube-secret \
env.d/development/multi_user_transcriber
.PHONY: create-env-files .PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project bootstrap: ## Prepare Docker images for the project
@@ -96,7 +94,6 @@ bootstrap: \
build: ## build the project containers build: ## build the project containers
@$(MAKE) build-backend @$(MAKE) build-backend
@$(MAKE) build-frontend @$(MAKE) build-frontend
@$(MAKE) build-agents
.PHONY: build .PHONY: build
build-backend: ## build the app-dev container build-backend: ## build the app-dev container
@@ -108,10 +105,6 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend @$(COMPOSE) build frontend
.PHONY: build-frontend .PHONY: build-frontend
build-agents: ## build the multi-user-transcriber agent container
@$(COMPOSE) build multi-user-transcriber
.PHONY: build-agents
down: ## stop and remove containers, networks, images, and volumes down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down @$(COMPOSE) down
.PHONY: down .PHONY: down
@@ -132,15 +125,10 @@ run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-summarize @$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary .PHONY: run-summary
run-agents: ## start the multi-user-transcriber agent
@$(COMPOSE) up --force-recreate -d multi-user-transcriber
.PHONY: run-agents
run: run:
run: ## start the wsgi (production) and development server run: ## start the wsgi (production) and development server
@$(MAKE) run-backend @$(MAKE) run-backend
@$(MAKE) run-summary @$(MAKE) run-summary
@$(MAKE) run-agents
@$(COMPOSE) up --force-recreate -d frontend @$(COMPOSE) up --force-recreate -d frontend
.PHONY: run .PHONY: run
@@ -277,12 +265,6 @@ env.d/development/kc_postgresql:
env.d/development/summary: env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary cp -n env.d/development/summary.dist env.d/development/summary
env.d/development/kube-secret:
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
env.d/development/multi_user_transcriber:
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
# -- Internationalization # -- Internationalization
env.d/development/crowdin: env.d/development/crowdin:
@@ -370,9 +352,15 @@ frontend-i18n-generate: \
# -- K8S # -- K8S
build-k8s-cluster: ## build the kubernetes cluster using kind build-k8s-cluster: ## build the kubernetes cluster using kind
build-k8s-cluster: \ ./bin/start-kind.sh
env.d/development/kube-secret \ .PHONY: build-k8s-cluster
./bin/start-kind.sh
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
./bin/install-external-secrets.sh
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster .PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
+58 -14
View File
@@ -2,7 +2,6 @@
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%"> <img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p> </p>
<p align="center"> <p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/"> <a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt=""> <img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
@@ -28,11 +27,13 @@
## La Suite Meet: Simple Video Conferencing ## La Suite Meet: Simple Video Conferencing
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/). Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features ### Features
- Optimized for stability in large meetings (+100 p.) - Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams - Support for multiple screen sharing streams
- Non-persistent, secure chat - Non-persistent, secure chat
- End-to-end encryption (coming soon) - End-to-end encryption with hybrid key distribution
- Meeting recording - Meeting recording
- Meeting transcription & Summary (currently in beta) - Meeting transcription & Summary (currently in beta)
- Telephony integration - Telephony integration
@@ -45,6 +46,54 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- selective subscription - selective subscription
- SVC codecs (VP9, AV1) - SVC codecs (VP9, AV1)
### End-to-end encryption
La Suite Meet supports end-to-end encryption (E2EE) for meetings, ensuring that the media server (LiveKit SFU) cannot access audio/video content. Two encryption modes are available:
#### Basic encryption
- Passphrase-based — the encryption key is embedded in the meeting URL hash (`#passphrase`)
- Uses LiveKit's built-in Worker + `crypto.subtle` (AES-GCM) for frame encryption
- Sharing the meeting link shares the encryption key
- No account or onboarding required
- Security depends on keeping the link private
#### Advanced encryption
- Key managed by [La Suite Encryption](https://github.com/suitenumerique/encryption) — the symmetric key never leaves the vault iframe
- Uses XChaCha20-Poly1305 (libsodium) via the VaultClient iframe for frame encryption
- Key distribution uses `vaultClient.shareKeys()` (hybrid PKI with X25519 + post-quantum slot)
- All participants must complete encryption onboarding (key generation + backup) before joining
- Requires a Chromium-based browser (Chrome, Edge, Brave) — uses the Insertable Streams API
**Frame encryption (both modes):**
- Codec header bytes (VP8 payload descriptor) are preserved unencrypted — required for proper RTP packetization
- Only the media payload is encrypted, with a per-frame random nonce
- The server (LiveKit SFU) only forwards encrypted data it cannot read
**Trust levels (advanced mode):**
| Badge | Level | Description |
|-------|-------|-------------|
| 🟢 Green shield | Verified | User completed encryption onboarding (public key registered). Identity cryptographically verified. |
| 🔵 Blue shield | Authenticated | User signed in via ProConnect/OIDC. Identity server-verified. |
| 🟡 Orange warning | Anonymous | User not signed in. Self-declared name. Admin should verify identity before accepting. |
**Security guarantees:**
- Encrypted rooms enforce restricted access (lobby approval required)
- Trust information (`is_authenticated`, `email`) comes from server-signed JWT tokens — cannot be spoofed
- Recording and transcription are not available in encrypted rooms (server cannot decrypt media)
**Configuration:**
```env
ENCRYPTION_ENABLED=true
ENCRYPTION_VAULT_URL=https://data.encryption.example.fr
ENCRYPTION_INTERFACE_URL=https://encryption.example.fr
```
When the encryption service is deployed and configured, rooms can use advanced encryption. Without it, only basic (passphrase) encryption is available.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr). La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
@@ -63,7 +112,6 @@ On the 25th of January 2026, David Amiel, Frances Minister for Civil Service
- [Philosophy](#philosophy) - [Philosophy](#philosophy)
- [Open source](#open-source) - [Open source](#open-source)
## Get started ## Get started
## Docs ## Docs
@@ -82,15 +130,15 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon. > Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
#### Known instances #### Known instances
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏 We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Access | | Url | Org | Access |
|---------------------------------------------------------------| --- | ------- | | ------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up| | [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up| | [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month | | [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | | [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
## Contributing ## Contributing
@@ -100,7 +148,6 @@ We <3 contributions of any kind, big and small:
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)) - Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) - Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy ## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics. Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
@@ -109,7 +156,6 @@ Our users come first. Were committed to making La Suite Meet as accessible an
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority. Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source ## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE). Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
@@ -121,7 +167,6 @@ To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr). Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞 ## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors"> <a href="https://github.com/suitenumerique/meet/graphs/contributors">
@@ -137,4 +182,3 @@ This project is tested with BrowserStack.
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique). Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html). Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
+2 -9
View File
@@ -2,7 +2,7 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject') load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet') namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak') DEV_ENV = os.getenv('DEV_ENV', 'dev')
if DEV_ENV == 'dev-dinum': if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"]) update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
@@ -95,19 +95,12 @@ docker_build(
) )
clean_old_images('localhost:5001/meet-livekit') clean_old_images('localhost:5001/meet-livekit')
load('ext://secret', 'secret_yaml_generic') k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_yaml(secret_yaml_generic(
name="secret-dev",
from_env_file="../env.d/development/kube-secret"
))
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
k8s_resource('minio-bucket', resource_deps=['minio']) k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server']) k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis']) k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis']) k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis']) k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend']) k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis']) k8s_resource('livekit-livekit-server', resource_deps=['redis'])
-8
View File
@@ -307,14 +307,6 @@ services:
- action: rebuild - action: rebuild
path: ./src/summary path: ./src/summary
multi-user-transcriber:
build:
context: ./src/agents
env_file:
- env.d/development/multi_user_transcriber
volumes:
- ./src/agents:/app
networks: networks:
default: default:
resource-server: resource-server:
+20 -3
View File
@@ -60,7 +60,7 @@
}, },
{ {
"username": "user-e2e-chromium", "username": "user-e2e-chromium",
"email": "user@chromium.e2e", "email": "user.test@chromium.test",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Chromium", "lastName": "Chromium",
"enabled": "true", "enabled": "true",
@@ -74,7 +74,7 @@
}, },
{ {
"username": "user-e2e-webkit", "username": "user-e2e-webkit",
"email": "user@webkit.e2e", "email": "user.test@webkit.test",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Webkit", "lastName": "Webkit",
"enabled": "true", "enabled": "true",
@@ -88,7 +88,7 @@
}, },
{ {
"username": "user-e2e-firefox", "username": "user-e2e-firefox",
"email": "user@firefox.e2e", "email": "user.test@firefox.test",
"firstName": "E2E", "firstName": "E2E",
"lastName": "Firefox", "lastName": "Firefox",
"enabled": "true", "enabled": "true",
@@ -845,6 +845,23 @@
"offline_access", "offline_access",
"microprofile-jwt" "microprofile-jwt"
] ]
},
{
"clientId": "encryption",
"name": "Encryption Service",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": [
"http://encryption.localhost:7200/auth/callback"
],
"webOrigins": [
"http://encryption.localhost:7200",
"http://data.encryption.localhost:7200"
],
"protocol": "openid-connect",
"fullScopeAllowed": true
} }
], ],
"clientScopes": [ "clientScopes": [
+7 -8
View File
@@ -39,16 +39,15 @@ COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/ ./dist/assets/fonts/
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root USER root
RUN apk update && apk upgrade libssl3 \
# Security patches for known CVEs libcrypto3 \
RUN apk update && apk upgrade \ libxml2>=2.12.7-r2 \
musl \ libxslt>=1.1.39-r2 \
musl-utils \ libexpat>=2.7.2-r0 \
zlib>=1.3.2-r0 \ libpng>=1.6.53-r0
&& apk del curl
USER nginx USER nginx
-4
View File
@@ -1,4 +0,0 @@
WHISPERX_BASE_URL=https://configure-your-url.com
WHISPERX_API_KEY=<key>
LLM_BASE_URL=https://configure-your-url.com
LLM_API_KEY=<key>
@@ -1,9 +0,0 @@
LIVEKIT_URL=ws://livekit:7880
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=secret
STT_PROVIDER=kyutai
ENABLE_SILERO_VAD=False
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.14.0" version = "1.12.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.4.5", "livekit-agents==1.4.5",
+6
View File
@@ -73,5 +73,11 @@ def get_frontend_configuration(request):
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES, "default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
}, },
} }
if settings.ENCRYPTION_ENABLED and settings.ENCRYPTION_VAULT_URL:
frontend_configuration["encryption"] = {
"enabled": True,
"vault_url": settings.ENCRYPTION_VAULT_URL,
"interface_url": settings.ENCRYPTION_INTERFACE_URL,
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION) frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration) return Response(frontend_configuration)
+60 -17
View File
@@ -30,8 +30,8 @@ class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "email", "full_name", "short_name", "timezone", "language"] fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"] read_only_fields = ["id", "sub", "email", "full_name", "short_name"]
class UserLightSerializer(serializers.ModelSerializer): class UserLightSerializer(serializers.ModelSerializer):
@@ -74,6 +74,23 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied( raise PermissionDenied(
"Only owners of a room can assign other users as owners." "Only owners of a room can assign other users as owners."
) )
# In advanced encrypted rooms, new accesses require an encrypted_symmetric_key
# so the new member can decrypt the room's streams. Without it, they'd have
# access but no key — which is useless and confusing.
# Future: a sharing UI (like Docs) could provide the key via vault shareKeys.
if not self.instance and "resource" in data:
resource = data["resource"]
if (
hasattr(resource, 'encryption_mode')
and resource.encryption_mode == models.EncryptionMode.ADVANCED
and not data.get("encrypted_symmetric_key")
):
raise serializers.ValidationError(
"Adding members to advanced encrypted rooms requires "
"an encrypted_symmetric_key for the new user."
)
return data return data
def validate_resource(self, resource): def validate_resource(self, resource):
@@ -98,7 +115,7 @@ class ResourceAccessSerializer(
class Meta: class Meta:
model = models.ResourceAccess model = models.ResourceAccess
fields = ["id", "user", "resource", "role"] fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"]
read_only_fields = ["id"] read_only_fields = ["id"]
def update(self, instance, validated_data): def update(self, instance, validated_data):
@@ -128,9 +145,27 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Room model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"] fields = ["id", "name", "slug", "configuration", "access_level", "pin_code", "encryption_mode"]
read_only_fields = ["id", "slug", "pin_code"] read_only_fields = ["id", "slug", "pin_code"]
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — prevent downgrading access level."""
instance = self.instance
if instance and instance.encryption_enabled and value != models.RoomAccessLevel.RESTRICTED:
raise serializers.ValidationError(
"Encrypted rooms require restricted access level to enforce lobby approval."
)
return value
def validate_encryption_mode(self, value):
"""Once encryption is enabled on a room, it cannot be disabled or downgraded."""
instance = self.instance
if instance and instance.encryption_enabled and value == models.EncryptionMode.NONE:
raise serializers.ValidationError(
"Encryption cannot be disabled once enabled on a room."
)
return value
def to_representation(self, instance): def to_representation(self, instance):
""" """
Add users only for administrator users. Add users only for administrator users.
@@ -172,18 +207,34 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room: if should_access_room:
room_id = f"{instance.id!s}" room_id = f"{instance.id!s}"
username = request.query_params.get("username", None) username = request.query_params.get("username", None)
# In encrypted rooms, authenticated users must use their real name from
# the OIDC profile (ProConnect) — they cannot choose an arbitrary name.
if instance.encryption_enabled and request.user.is_authenticated:
username = request.user.full_name or request.user.email
output["livekit"] = utils.generate_livekit_config( output["livekit"] = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
username=username, username=username,
configuration=configuration, configuration=configuration,
is_admin_or_owner=is_admin_or_owner, is_admin_or_owner=is_admin_or_owner,
encryption_mode=instance.encryption_mode,
) )
else: else:
del output["pin_code"] del output["pin_code"]
output["is_administrable"] = is_admin_or_owner output["is_administrable"] = is_admin_or_owner
# Include the current user's encrypted symmetric key for advanced E2EE
if request.user.is_authenticated and instance.encryption_mode == models.EncryptionMode.ADVANCED:
try:
access = instance.accesses.get(user=request.user)
if access.encrypted_symmetric_key:
output["encrypted_symmetric_key"] = access.encrypted_symmetric_key
except models.ResourceAccess.DoesNotExist:
pass
return output return output
@@ -265,7 +316,8 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
class RequestEntrySerializer(BaseValidationOnlySerializer): class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data.""" """Validate request entry data."""
username = serializers.CharField(required=True) username = serializers.CharField(required=True, allow_blank=True)
ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
class ParticipantEntrySerializer(BaseValidationOnlySerializer): class ParticipantEntrySerializer(BaseValidationOnlySerializer):
@@ -273,6 +325,9 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer):
participant_id = serializers.UUIDField(required=True) participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True) allow_entry = serializers.BooleanField(required=True)
encrypted_key = serializers.CharField(required=False, allow_blank=True, default='')
admin_ephemeral_public_key = serializers.CharField(required=False, allow_blank=True, default='')
encrypted_vault_key = serializers.CharField(required=False, allow_blank=True, default='')
class CreationCallbackSerializer(BaseValidationOnlySerializer): class CreationCallbackSerializer(BaseValidationOnlySerializer):
@@ -526,15 +581,3 @@ class CreateFileSerializer(ListFileSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
raise NotImplementedError("Update method can not be used.") raise NotImplementedError("Update method can not be used.")
class RaiseHandSerializer(BaseValidationOnlySerializer):
"""Serializer for raising or lowering a participant's hand in a room."""
raised = serializers.BooleanField()
class RenameParticipantSerializer(BaseValidationOnlySerializer):
"""Serializer for renaming a participant in a room."""
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
+61 -104
View File
@@ -10,7 +10,6 @@ from django.core.files.storage import default_storage
from django.db.models import Q from django.db.models import Q
from django.http import Http404 from django.http import Http404
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -65,7 +64,6 @@ from core.services.lobby import (
LobbyService, LobbyService,
) )
from core.services.participants_management import ( from core.services.participants_management import (
ParticipantNotFoundException,
ParticipantsManagement, ParticipantsManagement,
ParticipantsManagementException, ParticipantsManagementException,
) )
@@ -283,11 +281,32 @@ class RoomViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.""" """Set the current user as owner of the newly created room."""
encryption_mode = serializer.validated_data.get("encryption_mode", models.EncryptionMode.NONE)
# Block encrypted room creation if encryption is not enabled on this instance
if encryption_mode != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
# Advanced encryption requires the vault service to be configured
if encryption_mode == models.EncryptionMode.ADVANCED and not getattr(settings, 'ENCRYPTION_VAULT_URL', ''):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Advanced encryption requires the encryption service to be configured."}
)
# Encrypted rooms must use restricted access to enforce lobby approval
# before the encryption key is shared with participants.
if encryption_mode != models.EncryptionMode.NONE:
serializer.validated_data["access_level"] = models.RoomAccessLevel.RESTRICTED
room = serializer.save() room = serializer.save()
encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "")
models.ResourceAccess.objects.create( models.ResourceAccess.objects.create(
resource=room, resource=room,
user=self.request.user, user=self.request.user,
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
encrypted_symmetric_key=encrypted_symmetric_key,
) )
if callback_id := self.request.data.get("callback_id"): if callback_id := self.request.data.get("callback_id"):
@@ -316,6 +335,12 @@ class RoomViewSet(
options = serializer.validated_data.get("options") options = serializer.validated_data.get("options")
room = self.get_object() room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"detail": "Recording is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# May raise exception if an active or initiated recording already exist for the room # May raise exception if an active or initiated recording already exist for the room
recording = models.Recording.objects.create( recording = models.Recording.objects.create(
room=room, room=room,
@@ -398,12 +423,28 @@ class RoomViewSet(
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
room = self.get_object() room = self.get_object()
validated_data = serializer.validated_data
# Advanced encrypted rooms require authentication
if room.encryption_mode == models.EncryptionMode.ADVANCED and not request.user.is_authenticated:
return drf_response.Response(
{"detail": "This meeting requires authentication to join."},
status=drf_status.HTTP_403_FORBIDDEN,
)
# In encrypted rooms, authenticated users must use their real name
# from the OIDC profile — they cannot choose an arbitrary name.
if room.encryption_enabled and request.user.is_authenticated:
validated_data["username"] = (
request.user.full_name or request.user.email
)
lobby_service = LobbyService() lobby_service = LobbyService()
participant, livekit = lobby_service.request_entry( participant, livekit = lobby_service.request_entry(
room=room, room=room,
request=request, request=request,
**serializer.validated_data, **validated_data,
) )
response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id) lobby_service.prepare_response(response, participant.id)
@@ -439,6 +480,9 @@ class RoomViewSet(
room_id=room.id, room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")), participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"), allow_entry=serializer.validated_data.get("allow_entry"),
encrypted_key=serializer.validated_data.get("encrypted_key", ''),
admin_ephemeral_public_key=serializer.validated_data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=serializer.validated_data.get("encrypted_vault_key", ''),
) )
return drf_response.Response({"message": "Participant was updated."}) return drf_response.Response({"message": "Participant was updated."})
@@ -466,6 +510,14 @@ class RoomViewSet(
lobby_service = LobbyService() lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id) participants = lobby_service.list_waiting_participants(room.id)
# Only expose email and ephemeral keys in encrypted rooms.
# Strip them otherwise to avoid leaking personal data.
if not room.encryption_enabled:
for p in participants:
p.pop("email", None)
p.pop("ephemeral_public_key", None)
return drf_response.Response({"participants": participants}) return drf_response.Response({"participants": participants})
@decorators.action( @decorators.action(
@@ -568,6 +620,12 @@ class RoomViewSet(
room = self.get_object() room = self.get_object()
if room.encryption_enabled:
return drf_response.Response(
{"error": "Transcription is not available in encrypted rooms."},
status=drf_status.HTTP_403_FORBIDDEN,
)
try: try:
SubtitleService().start_subtitle(room) SubtitleService().start_subtitle(room)
except SubtitleException: except SubtitleException:
@@ -600,11 +658,6 @@ class RoomViewSet(
identity=str(serializer.validated_data["participant_identity"]), identity=str(serializer.validated_data["participant_identity"]),
track_sid=serializer.validated_data["track_sid"], track_sid=serializer.validated_data["track_sid"],
) )
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException: except ParticipantsManagementException:
return drf_response.Response( return drf_response.Response(
{"error": "Failed to mute participant"}, {"error": "Failed to mute participant"},
@@ -643,11 +696,6 @@ class RoomViewSet(
permission=permission.model_dump() if permission else None, permission=permission.model_dump() if permission else None,
name=serializer.validated_data.get("name"), name=serializer.validated_data.get("name"),
) )
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException: except ParticipantsManagementException:
return drf_response.Response( return drf_response.Response(
{"error": "Failed to update participant"}, {"error": "Failed to update participant"},
@@ -680,11 +728,6 @@ class RoomViewSet(
room_name=str(room.pk), room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]), identity=str(serializer.validated_data["participant_identity"]),
) )
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException: except ParticipantsManagementException:
return drf_response.Response( return drf_response.Response(
{"error": "Failed to remove participant"}, {"error": "Failed to remove participant"},
@@ -695,92 +738,6 @@ class RoomViewSet(
{"status": "success"}, status=drf_status.HTTP_200_OK {"status": "success"}, status=drf_status.HTTP_200_OK
) )
@decorators.action(
detail=True,
methods=["post"],
url_path="toggle-hand",
url_name="toggle-hand",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def toggle_hand(self, request, pk=None): # pylint: disable=unused-argument
"""Raise or lower the current participant's hand in the room."""
room = self.get_object()
serializer = serializers.RaiseHandSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
# LiveKit uses the handRaisedAt participant attribute to signal hand state.
# An empty string means the hand is lowered; a non-empty ISO 8601 timestamp
# means the hand is raised. The timestamp is used by clients to determine
# the order in which participants raised their hands.
hand_raised_at = (
timezone.now().isoformat() if serializer.validated_data["raised"] else ""
)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
attributes={"handRaisedAt": hand_raised_at},
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant hand state"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="rename",
url_name="rename",
permission_classes=[permissions.HasLiveKitRoomAccess],
authentication_classes=[LiveKitTokenAuthentication],
)
def rename(self, request, pk=None): # pylint: disable=unused-argument
"""Rename the current participant in the room."""
room = self.get_object()
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
identity = request.auth.identity
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=identity,
name=serializer.validated_data["name"],
)
except ParticipantNotFoundException:
return drf_response.Response(
{"error": "Participant not found"},
status=drf_status.HTTP_404_NOT_FOUND,
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to rename participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"},
status=drf_status.HTTP_200_OK,
)
class ResourceAccessViewSet( class ResourceAccessViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
+2 -11
View File
@@ -14,19 +14,10 @@ class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user.""" """Authenticate using LiveKit token and load the associated Django user."""
def authenticate(self, request): def authenticate(self, request):
auth_header = request.headers.get("Authorization") token = request.data.get("token")
if not token:
if not auth_header:
return None # No authentication attempted return None # No authentication attempted
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise exceptions.AuthenticationFailed(
"Authorization header must be: Bearer <token>"
)
token = parts[1]
try: try:
verifier = TokenVerifier( verifier = TokenVerifier(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"], api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0018_rename_active_application_is_active"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_enabled",
field=models.BooleanField(
default=False,
help_text="Whether end-to-end encryption is enabled for this room.",
verbose_name="Encryption enabled",
),
),
]
@@ -0,0 +1,51 @@
"""Replace encryption_enabled boolean with encryption_mode enum."""
from django.db import migrations, models
def migrate_encryption_enabled_to_mode(apps, schema_editor):
"""Convert existing encryption_enabled=True rooms to encryption_mode='basic'."""
Room = apps.get_model("core", "Room")
Room.objects.filter(encryption_enabled=True).update(encryption_mode="basic")
def migrate_mode_to_encryption_enabled(apps, schema_editor):
"""Reverse: set encryption_enabled=True for any non-'none' encryption_mode."""
Room = apps.get_model("core", "Room")
Room.objects.exclude(encryption_mode="none").update(encryption_enabled=True)
class Migration(migrations.Migration):
dependencies = [
("core", "0019_room_encryption_enabled"),
]
operations = [
# 1. Add the new encryption_mode field
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Basic encryption"),
("advanced", "Advanced encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
# 2. Migrate existing data
migrations.RunPython(
migrate_encryption_enabled_to_mode,
migrate_mode_to_encryption_enabled,
),
# 3. Remove the old boolean field
migrations.RemoveField(
model_name="room",
name="encryption_enabled",
),
]
@@ -0,0 +1,23 @@
"""Add encrypted_symmetric_key to ResourceAccess for advanced E2EE mode."""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0020_room_encryption_mode"),
]
operations = [
migrations.AddField(
model_name="resourceaccess",
name="encrypted_symmetric_key",
field=models.TextField(
blank=True,
default="",
help_text="Vault-wrapped symmetric encryption key for advanced E2EE mode. Each user's copy is encrypted for their own vault public key.",
verbose_name="Encrypted symmetric key",
),
),
]
+29
View File
@@ -98,6 +98,14 @@ class RoomAccessLevel(models.TextChoices):
RESTRICTED = "restricted", _("Restricted Access") RESTRICTED = "restricted", _("Restricted Access")
class EncryptionMode(models.TextChoices):
"""Encryption mode choices for rooms."""
NONE = "none", _("No encryption")
BASIC = "basic", _("Basic encryption")
ADVANCED = "advanced", _("Advanced encryption")
class BaseModel(models.Model): class BaseModel(models.Model):
""" """
Serves as an abstract base model for other models, ensuring that records are validated Serves as an abstract base model for other models, ensuring that records are validated
@@ -324,6 +332,15 @@ class ResourceAccess(BaseModel):
role = models.CharField( role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
) )
encrypted_symmetric_key = models.TextField(
blank=True,
default='',
verbose_name=_("Encrypted symmetric key"),
help_text=_(
"Vault-wrapped symmetric encryption key for advanced E2EE mode. "
"Each user's copy is encrypted for their own vault public key."
),
)
class Meta: class Meta:
db_table = "meet_resource_access" db_table = "meet_resource_access"
@@ -388,6 +405,13 @@ class Room(Resource):
choices=RoomAccessLevel.choices, choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
) )
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
configuration = models.JSONField( configuration = models.JSONField(
blank=True, blank=True,
default=dict, default=dict,
@@ -442,6 +466,11 @@ class Room(Resource):
"""Check if a room is public""" """Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC return self.access_level == RoomAccessLevel.PUBLIC
@property
def encryption_enabled(self):
"""Check if any encryption mode is active."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod @staticmethod
def generate_unique_pin_code(length): def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code""" """Generate a unique n-digit PIN code"""
+90 -6
View File
@@ -46,15 +46,36 @@ class LobbyParticipant:
username: str username: str
color: str color: str
id: str id: str
is_authenticated: bool = False
email: Optional[str] = None
suite_user_id: Optional[str] = None
ephemeral_public_key: str = ''
encrypted_key: str = ''
admin_ephemeral_public_key: str = ''
encrypted_vault_key: str = ''
def to_dict(self) -> Dict[str, str]: def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation.""" """Serialize the participant object to a dict representation."""
return { result = {
"status": self.status.value, "status": self.status.value,
"username": self.username, "username": self.username,
"id": self.id, "id": self.id,
"color": self.color, "color": self.color,
"is_authenticated": self.is_authenticated,
} }
if self.email:
result["email"] = self.email
if self.suite_user_id:
result["suite_user_id"] = self.suite_user_id
if self.ephemeral_public_key:
result["ephemeral_public_key"] = self.ephemeral_public_key
if self.encrypted_key:
result["encrypted_key"] = self.encrypted_key
if self.admin_ephemeral_public_key:
result["admin_ephemeral_public_key"] = self.admin_ephemeral_public_key
if self.encrypted_vault_key:
result["encrypted_vault_key"] = self.encrypted_vault_key
return result
@classmethod @classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant": def from_dict(cls, data: dict) -> "LobbyParticipant":
@@ -68,6 +89,13 @@ class LobbyParticipant:
username=data["username"], username=data["username"],
id=data["id"], id=data["id"],
color=data["color"], color=data["color"],
is_authenticated=data.get("is_authenticated", False),
email=data.get("email"),
suite_user_id=data.get("suite_user_id"),
ephemeral_public_key=data.get("ephemeral_public_key", ''),
encrypted_key=data.get("encrypted_key", ''),
admin_ephemeral_public_key=data.get("admin_ephemeral_public_key", ''),
encrypted_vault_key=data.get("encrypted_vault_key", ''),
) )
except (KeyError, ValueError) as e: except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:") logger.exception("Error creating Participant from dict:")
@@ -99,7 +127,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME, key=settings.LOBBY_COOKIE_NAME,
value=participant_id, value=participant_id,
httponly=True, httponly=True,
secure=True, secure=not settings.DEBUG,
samesite="Lax", samesite="Lax",
) )
@@ -111,11 +139,16 @@ class LobbyService:
1. The room is public (open to everyone) 1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated 2. The room has TRUSTED access level and the user is authenticated
Encrypted rooms never bypass the lobby — participants must go through
the lobby key exchange to receive the encryption key.
Note: Room access levels can change while participants are waiting in the lobby. Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting. participants who have already begun waiting.
""" """
if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none':
return False
return room.is_public or ( return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated and user.is_authenticated
@@ -126,6 +159,7 @@ class LobbyService:
room, room,
request, request,
username: str, username: str,
ephemeral_public_key: str = '',
) -> Tuple[LobbyParticipant, Optional[Dict]]: ) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant. """Request entry to a room for a participant.
@@ -164,19 +198,42 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
livekit_config = None livekit_config = None
if participant is None: if participant is None:
participant = self.enter(room.id, participant_id, username) participant = self.enter(
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id) self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED: elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room # If the joiner comes back with a different ephemeral key (e.g. browser
# closed and reopened), they can no longer decrypt the encrypted symmetric
# key. Reset them to WAITING so the admin re-accepts with the new key.
if (
ephemeral_public_key
and participant.ephemeral_public_key
and ephemeral_public_key != participant.ephemeral_public_key
):
participant = self.enter(
room.id, participant_id, username,
is_authenticated=request.user.is_authenticated,
email=getattr(request.user, 'email', None) if request.user.is_authenticated else None,
suite_user_id=str(request.user.sub) if request.user.is_authenticated else None,
ephemeral_public_key=ephemeral_public_key,
)
return participant, None
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
@@ -185,6 +242,7 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
is_admin_or_owner=False, is_admin_or_owner=False,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
@@ -201,7 +259,11 @@ class LobbyService:
) )
def enter( def enter(
self, room_id: UUID, participant_id: str, username: str self, room_id: UUID, participant_id: str, username: str,
is_authenticated: bool = False,
email: Optional[str] = None,
suite_user_id: Optional[str] = None,
ephemeral_public_key: str = '',
) -> LobbyParticipant: ) -> LobbyParticipant:
"""Add participant to waiting lobby. """Add participant to waiting lobby.
@@ -216,6 +278,10 @@ class LobbyService:
username=username, username=username,
id=participant_id, id=participant_id,
color=color, color=color,
is_authenticated=is_authenticated,
email=email,
suite_user_id=suite_user_id,
ephemeral_public_key=ephemeral_public_key,
) )
try: try:
@@ -284,6 +350,9 @@ class LobbyService:
room_id: UUID, room_id: UUID,
participant_id: str, participant_id: str,
allow_entry: bool, allow_entry: bool,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None: ) -> None:
"""Handle decision on participant entry. """Handle decision on participant entry.
@@ -302,7 +371,13 @@ class LobbyService:
"timeout": settings.LOBBY_DENIED_TIMEOUT, "timeout": settings.LOBBY_DENIED_TIMEOUT,
} }
self._update_participant_status(room_id, participant_id, **decision) self._update_participant_status(
room_id, participant_id,
encrypted_key=encrypted_key,
admin_ephemeral_public_key=admin_ephemeral_public_key,
encrypted_vault_key=encrypted_vault_key,
**decision,
)
def _update_participant_status( def _update_participant_status(
self, self,
@@ -310,6 +385,9 @@ class LobbyService:
participant_id: str, participant_id: str,
status: LobbyParticipantStatus, status: LobbyParticipantStatus,
timeout: int, timeout: int,
encrypted_key: str = '',
admin_ephemeral_public_key: str = '',
encrypted_vault_key: str = '',
) -> None: ) -> None:
"""Update participant status with appropriate timeout.""" """Update participant status with appropriate timeout."""
@@ -330,6 +408,12 @@ class LobbyService:
raise raise
participant.status = status participant.status = status
if encrypted_key:
participant.encrypted_key = encrypted_key
if admin_ephemeral_public_key:
participant.admin_ephemeral_public_key = admin_ephemeral_public_key
if encrypted_vault_key:
participant.encrypted_vault_key = encrypted_vault_key
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
@@ -27,10 +27,6 @@ class ParticipantsManagementException(Exception):
"""Exception raised when a participant management operations fail.""" """Exception raised when a participant management operations fail."""
class ParticipantNotFoundException(ParticipantsManagementException):
"""Raised when the target participant does not exist in the room."""
class ParticipantsManagement: class ParticipantsManagement:
"""Service for managing participants.""" """Service for managing participants."""
@@ -51,14 +47,6 @@ class ParticipantsManagement:
) )
except TwirpError as e: except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping muting",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception( logger.exception(
"Unexpected error muting participant %s for room %s", "Unexpected error muting participant %s for room %s",
identity, identity,
@@ -92,14 +80,6 @@ class ParticipantsManagement:
RoomParticipantIdentity(room=room_name, identity=identity) RoomParticipantIdentity(room=room_name, identity=identity)
) )
except TwirpError as e: except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping removing",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception( logger.exception(
"Unexpected error removing participant %s for room %s", "Unexpected error removing participant %s for room %s",
identity, identity,
@@ -137,14 +117,6 @@ class ParticipantsManagement:
) )
except TwirpError as e: except TwirpError as e:
if e.code == "not_found":
logger.warning(
"Participant %s not found in room %s, skipping update",
identity,
room_name,
)
raise ParticipantNotFoundException("Participant does not exist") from e
logger.exception( logger.exception(
"Unexpected error updating participant %s for room %s", "Unexpected error updating participant %s for room %s",
identity, identity,
@@ -91,7 +91,7 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
client = APIClient() client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError( mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500 msg="Internal server error", code=500, status=500
) )
room = RoomFactory() room = RoomFactory()
@@ -387,7 +387,7 @@ def test_update_participant_unexpected_twirp_error(mock_livekit_client):
client = APIClient() client = APIClient()
mock_livekit_client.room.update_participant.side_effect = TwirpError( mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500 msg="Internal server error", code=500, status=500
) )
room = RoomFactory() room = RoomFactory()
@@ -526,7 +526,7 @@ def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
client = APIClient() client = APIClient()
mock_livekit_client.room.remove_participant.side_effect = TwirpError( mock_livekit_client.room.remove_participant.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500 msg="Internal server error", code=500, status=500
) )
room = RoomFactory() room = RoomFactory()
@@ -545,55 +545,3 @@ def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
assert response.data == {"error": "Failed to remove participant"} assert response.data == {"error": "Failed to remove participant"}
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
def test_update_participant_not_found(mock_livekit_client):
"""Test update participant returns 404 when the participant no longer exists in the room."""
client = APIClient()
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_not_found(mock_livekit_client):
"""Test remove participant returns 404 when the participant no longer exists in the room."""
client = APIClient()
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
@@ -1,574 +0,0 @@
"""
Test rooms API endpoints: toggle hand and rename participant.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from unittest import mock
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.urls import reverse
import pytest
from freezegun import freeze_time
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core import utils
from core.factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
@pytest.fixture
def room():
"""Create a room."""
return RoomFactory()
@pytest.fixture
def user():
"""Create a user."""
return UserFactory()
@pytest.fixture
def token(room, user):
"""Generate a real LiveKit JWT for the user in the room."""
return utils.generate_token(room=str(room.id), user=user)
@pytest.fixture
def anonymous_token(room):
"""Generate a real LiveKit JWT for an anonymous user in the room."""
return utils.generate_token(
room=str(room.id),
user=AnonymousUser(),
participant_id="anon-participant-id",
)
# ---
# toggle-hand
# ---
def test_toggle_hand_raise_success(mock_livekit_client, room, token):
"""Test successfully raising a participant's hand."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_lower_success(mock_livekit_client, room, token):
"""Test successfully lowering a participant's hand."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": False}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_raise_sets_timestamp(mock_livekit_client, room, token):
"""Test that raising a hand sets a non-empty ISO timestamp as the attribute."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].attributes["handRaisedAt"] != ""
def test_toggle_hand_identity_derived_from_token(
mock_livekit_client, room, token, user
):
"""Test that the participant identity is derived from the token, not supplied by the client."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == str(user.sub)
def test_toggle_hand_missing_raised_field(room, token):
"""Test toggle hand with missing raised field returns 400."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "raised" in response.data
def test_toggle_hand_invalid_raised_field(room, token):
"""Test toggle hand with non-boolean raised field returns 400."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": "not-a-boolean"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_toggle_hand_forbidden_without_token(room):
"""Test toggle hand returns 403 when no LiveKit token is provided."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(url, {"raised": True}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_forbidden_token_for_wrong_room(user):
"""Test toggle hand returns 403 when the token is scoped to a different room."""
wrong_room = RoomFactory()
target_room = RoomFactory()
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": target_room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {wrong_token}"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_unexpected_twirp_error(mock_livekit_client, room, token):
"""Test toggle hand when LiveKit API raises TwirpError."""
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500
)
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to update participant hand state"}
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_raise_success_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test successfully raising hand as an anonymous participant."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_toggle_hand_lower_success_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test successfully lowering hand as an anonymous participant."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": False},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].attributes["handRaisedAt"] == ""
def test_toggle_hand_identity_derived_from_token_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test that identity is derived from participant_id for anonymous users."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
client.post(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == "anon-participant-id"
# ---
# rename
# ---
def test_rename_participant_success(mock_livekit_client, room, token):
"""Test successfully renaming a participant."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_rename_participant_sets_correct_name(mock_livekit_client, room, token):
"""Test that rename passes the correct name to LiveKit."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url, {"name": "Jane Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].name == "Jane Doe"
def test_rename_participant_uses_identity_from_token(
mock_livekit_client, room, token, user
):
"""Test that rename derives participant identity from the LiveKit token, not the request."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == str(user.sub)
def test_rename_participant_empty_name(room, token):
"""Test rename with an empty name returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": ""}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
def test_rename_participant_missing_name(room, token):
"""Test rename with missing name field returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
def test_rename_participant_name_too_long(room, token):
"""Test rename with a name exceeding 255 characters returns 400."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "a" * 256}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "name" in response.data
def test_rename_participant_forbidden_without_token(room):
"""Test rename returns 403 when no LiveKit token is provided."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(url, {"name": "John Doe"}, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_forbidden_token_for_wrong_room(user):
"""Test rename returns 403 when the token is scoped to a different room."""
wrong_room = RoomFactory()
target_room = RoomFactory()
wrong_token = utils.generate_token(room=str(wrong_room.id), user=user)
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
response = client.post(
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {wrong_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, token):
"""Test rename when LiveKit API raises TwirpError."""
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500
)
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to rename participant"}
mock_livekit_client.aclose.assert_called_once()
def test_rename_participant_success_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test successfully renaming an anonymous participant."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_rename_participant_uses_identity_from_token_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test that rename derives identity from participant_id for anonymous users."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].identity == "anon-participant-id"
def test_rename_participant_sets_correct_name_anonymous(
mock_livekit_client, room, anonymous_token
):
"""Test that rename passes the correct name to LiveKit for anonymous users."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
call_kwargs = mock_livekit_client.room.update_participant.call_args
assert call_kwargs[0][0].name == "Guest User"
def test_rename_participant_forbidden_anonymous_token_for_wrong_room(anonymous_token):
"""Test rename returns 403 when anonymous token is scoped to a different room."""
target_room = RoomFactory()
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": target_room.id})
response = client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
# ---
# expired / malformed / missing room — shared cases
# ---
@pytest.fixture
@freeze_time("2023-01-15 12:00:00")
def expired_token(room, user):
"""Generate a LiveKit JWT frozen in the past, guaranteed to be expired."""
return utils.generate_token(room=str(room.id), user=user)
def test_toggle_hand_expired_token(room, expired_token):
"""Test toggle hand returns 403 when the LiveKit token is expired."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_expired_token(room, expired_token):
"""Test rename returns 403 when the LiveKit token is expired."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {expired_token}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_malformed_token(room):
"""Test toggle hand returns 403 when the LiveKit token is malformed."""
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url,
{"raised": True},
format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_toggle_hand_room_not_found(user):
"""Test toggle hand returns 404 when the room does not exist."""
non_existent_room_id = uuid4()
token = utils.generate_token(room=str(non_existent_room_id), user=user)
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": non_existent_room_id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_toggle_hand_participant_not_found(mock_livekit_client, room, token):
"""Test toggle hand returns 404 when the participant no longer exists in the room."""
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
client = APIClient()
url = reverse("rooms-toggle-hand", kwargs={"pk": room.id})
response = client.post(
url, {"raised": True}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
def test_rename_participant_malformed_token(room):
"""Test rename returns 403 when the LiveKit token is malformed."""
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "John Doe"},
format="json",
HTTP_AUTHORIZATION="Bearer this-is-not-a-valid-jwt",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_rename_participant_room_not_found(user):
"""Test rename returns 404 when the room does not exist."""
non_existent_room_id = uuid4()
token = utils.generate_token(room=str(non_existent_room_id), user=user)
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": non_existent_room_id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_rename_participant_not_found(mock_livekit_client, room, token):
"""Test rename returns 404 when the participant no longer exists in the room."""
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="participant does not exist", code="not_found", status=404
)
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.data == {"error": "Participant not found"}
mock_livekit_client.aclose.assert_called_once()
@@ -108,9 +108,7 @@ def test_start_subtitle_invalid_token():
client.force_login(user) client.force_login(user)
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
{},
HTTP_AUTHORIZATION="Bearer invalid-token",
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -127,8 +125,7 @@ def test_start_subtitle_disabled_by_default(mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {"token": mock_livekit_token},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
) )
assert response.status_code == 404 assert response.status_code == 404
@@ -147,8 +144,7 @@ def test_start_subtitle_valid_token(
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {"token": mock_livekit_token},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
) )
assert response.status_code == 200 assert response.status_code == 200
@@ -172,13 +168,12 @@ def test_start_subtitle_twirp_error(
client = APIClient() client = APIClient()
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError( mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
msg="Internal server error", code="unknown", status=500 msg="Internal server error", code=500, status=500
) )
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {"token": mock_livekit_token},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
) )
assert response.status_code == 500 assert response.status_code == 500
@@ -197,8 +192,7 @@ def test_start_subtitle_wrong_room(settings, mock_livekit_token):
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {"token": mock_livekit_token},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -211,15 +205,14 @@ def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
"""Test that tokens signed with incorrect signature are rejected.""" """Test that tokens signed with incorrect signature are rejected."""
settings.ROOM_SUBTITLE_ENABLED = True settings.ROOM_SUBTITLE_ENABLED = True
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret-padded-to-32-bytes!!" settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
room = RoomFactory() room = RoomFactory()
client = APIClient() client = APIClient()
response = client.post( response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{}, {"token": mock_livekit_token},
HTTP_AUTHORIZATION=f"Bearer {mock_livekit_token}",
) )
assert response.status_code == 403 assert response.status_code == 403
@@ -47,7 +47,7 @@ def mock_livekit_config(settings):
"""Mock LiveKit configuration.""" """Mock LiveKit configuration."""
settings.LIVEKIT_CONFIGURATION = { settings.LIVEKIT_CONFIGURATION = {
"api_key": "test_api_key", "api_key": "test_api_key",
"api_secret": "test_api_secret_padded_to_32bytes!", "api_secret": "test_api_secret",
"url": "https://test-livekit.example.com/", "url": "https://test-livekit.example.com/",
} }
return settings.LIVEKIT_CONFIGURATION return settings.LIVEKIT_CONFIGURATION
@@ -1,541 +0,0 @@
"""
Tests for JWT token service.
"""
# pylint: disable=W0212,W0621
import uuid
from unittest import mock
from django.core.exceptions import ImproperlyConfigured
import jwt as pyjwt
import pytest
from freezegun import freeze_time
from core.services.jwt_token import (
JwtTokenService,
TokenDecodeError,
TokenExpiredError,
TokenInvalidError,
)
# -- Fixtures --
@pytest.fixture
def jwt_service():
"""Create a JWT token service for testing."""
return JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="test-issuer",
audience="test-audience",
expiration_seconds=3600,
token_type="Bearer",
)
@pytest.fixture
def mock_user():
"""Create a mock user with a string ID."""
user = mock.Mock()
user.id = "test-user-id"
return user
# -- __init__ / Configuration --
def test_init_missing_secret_key():
"""Missing secret key should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
JwtTokenService(
secret_key="",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_none_secret_key():
"""None secret key should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Secret key is required"):
JwtTokenService(
secret_key=None,
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_missing_algorithm():
"""Missing algorithm should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_none_algorithm():
"""None algorithm should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Algorithm is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm=None,
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
def test_init_missing_token_type():
"""Missing token type should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="",
)
def test_init_none_token_type():
"""None token type should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Token's type is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type=None,
)
def test_init_none_expiration_seconds():
"""None expiration seconds should raise ImproperlyConfigured."""
with pytest.raises(ImproperlyConfigured, match="Expiration's seconds is required"):
JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=None,
token_type="Bearer",
)
def test_init_zero_expiration_seconds_is_accepted():
"""expiration_seconds=0 is falsy but should be accepted — token expires immediately."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=0,
token_type="Bearer",
)
assert service._expiration_seconds == 0
def test_init_stores_config_correctly():
"""All config values should be stored correctly on the instance."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="my-issuer",
audience="my-audience",
expiration_seconds=1800,
token_type="Bearer",
)
assert service._key == "test-secret-padded-to-32-bytes!!"
assert service._algorithm == "HS256"
assert service._issuer == "my-issuer"
assert service._audience == "my-audience"
assert service._expiration_seconds == 1800
assert service._token_type == "Bearer"
# -- generate_jwt / Return shape --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_always_returns_required_keys(jwt_service, mock_user):
"""Response always contains access_token, token_type, and expires_in."""
result = jwt_service.generate_jwt(mock_user, scope="read")
assert "access_token" in result
assert "token_type" in result
assert "expires_in" in result
assert result["token_type"] == "Bearer"
assert result["expires_in"] == 3600
assert isinstance(result["access_token"], str)
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_present_when_provided(jwt_service, mock_user):
"""scope key should be present in response when scope is provided."""
result = jwt_service.generate_jwt(mock_user, scope="read write")
assert result["scope"] == "read write"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_absent_when_empty(jwt_service, mock_user):
"""scope key should be absent from response when scope is empty."""
result = jwt_service.generate_jwt(mock_user, scope="")
assert "scope" not in result
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_absent_when_none(jwt_service, mock_user):
"""scope key should be absent from response when scope is None."""
result = jwt_service.generate_jwt(mock_user, scope=None)
assert "scope" not in result
# -- generate_jwt / Payload correctness --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_payload_contains_required_claims(jwt_service, mock_user):
"""Payload should always contain iat, exp, and user_id."""
result = jwt_service.generate_jwt(mock_user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["iat"] == 1673784000
assert payload["exp"] == 1673787600
assert payload["user_id"] == "test-user-id"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_exp_is_now_plus_expiration_seconds(mock_user):
"""exp should equal iat + expiration_seconds exactly."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=900,
token_type="Bearer",
)
result = service.generate_jwt(mock_user, scope="read")
payload = service.decode_jwt(result["access_token"])
assert payload["exp"] - payload["iat"] == 900
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_iss_included_when_set(jwt_service, mock_user):
"""iss should be present in payload when issuer is non-empty."""
result = jwt_service.generate_jwt(mock_user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["iss"] == "test-issuer"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_aud_included_when_set(jwt_service, mock_user):
"""aud should be present in payload when audience is non-empty."""
result = jwt_service.generate_jwt(mock_user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["aud"] == "test-audience"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_iss_absent_when_empty(mock_user):
"""iss should be absent from payload when issuer is empty string."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="",
audience="",
expiration_seconds=3600,
token_type="Bearer",
)
result = service.generate_jwt(mock_user, scope="read")
payload = pyjwt.decode(
result["access_token"],
"test-secret-padded-to-32-bytes!!",
algorithms=["HS256"],
options={"verify_aud": False},
)
assert "iss" not in payload
assert "aud" not in payload
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_iss_absent_when_none(mock_user):
"""iss should be absent from payload when issuer is None."""
service = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer=None,
audience=None,
expiration_seconds=3600,
token_type="Bearer",
)
result = service.generate_jwt(mock_user, scope="read")
payload = pyjwt.decode(
result["access_token"],
"test-secret-padded-to-32-bytes!!",
algorithms=["HS256"],
options={"verify_aud": False},
)
assert "iss" not in payload
assert "aud" not in payload
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_scope_absent_from_payload_when_empty(jwt_service, mock_user):
"""scope should be absent from payload when not provided."""
result = jwt_service.generate_jwt(mock_user, scope="")
payload = pyjwt.decode(
result["access_token"],
"test-secret-padded-to-32-bytes!!",
algorithms=["HS256"],
issuer="test-issuer",
audience="test-audience",
)
assert "scope" not in payload
# -- generate_jwt / extra_payload handling --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_none_does_not_crash(jwt_service, mock_user):
"""extra_payload=None should not crash and produce a valid token."""
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload=None)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "test-user-id"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_non_colliding_keys_preserved(
jwt_service, mock_user
):
"""Non-colliding extra_payload keys should appear in decoded token."""
result = jwt_service.generate_jwt(
mock_user,
scope="read",
extra_payload={"client_id": "my-app", "delegated": True},
)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["client_id"] == "my-app"
assert payload["delegated"] is True
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_colliding_iat_overwritten(jwt_service, mock_user):
"""iat in extra_payload should be overwritten by the service."""
result = jwt_service.generate_jwt(mock_user, scope="read", extra_payload={"iat": 0})
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["iat"] == 1673784000
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_colliding_exp_overwritten(jwt_service, mock_user):
"""exp in extra_payload should be overwritten by the service."""
result = jwt_service.generate_jwt(
mock_user, scope="read", extra_payload={"exp": 9999999999}
)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["exp"] == 1673787600
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_colliding_user_id_overwritten(
jwt_service, mock_user
):
"""user_id in extra_payload should be overwritten by the service."""
result = jwt_service.generate_jwt(
mock_user, scope="read", extra_payload={"user_id": "hacked"}
)
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "test-user-id"
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_extra_payload_not_mutated(jwt_service, mock_user):
"""generate_jwt should not mutate the original extra_payload dict."""
extra = {"client_id": "my-app"}
jwt_service.generate_jwt(mock_user, scope="read", extra_payload=extra)
assert extra == {"client_id": "my-app"}
# -- generate_jwt / user.id casting --
@freeze_time("2023-01-15 12:00:00")
def test_generate_jwt_user_id_cast_from_uuid(jwt_service):
"""user.id as UUID should be cast to str in payload."""
user = mock.Mock()
user.id = uuid.UUID("12345678-1234-5678-1234-567812345678")
result = jwt_service.generate_jwt(user, scope="read")
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "12345678-1234-5678-1234-567812345678"
# -- decode_jwt / Happy path --
def test_decode_jwt_roundtrip(jwt_service, mock_user):
"""Valid token should decode to correct payload."""
with freeze_time("2023-01-15 12:00:00"):
result = jwt_service.generate_jwt(
mock_user, scope="read", extra_payload={"client_id": "my-app"}
)
with freeze_time("2023-01-15 12:30:00"):
payload = jwt_service.decode_jwt(result["access_token"])
assert payload["user_id"] == "test-user-id"
assert payload["scope"] == "read"
assert payload["client_id"] == "my-app"
assert payload["iss"] == "test-issuer"
assert payload["aud"] == "test-audience"
# -- decode_jwt / Error mapping --
def test_decode_jwt_expired_raises_token_expired_error(jwt_service, mock_user):
"""Expired token should raise TokenExpiredError."""
with freeze_time("2023-01-15 12:00:00"):
result = jwt_service.generate_jwt(mock_user, scope="read")
with freeze_time("2099-01-01 00:00:00"):
with pytest.raises(TokenExpiredError):
jwt_service.decode_jwt(result["access_token"])
def test_decode_jwt_wrong_issuer_raises_token_invalid_error(mock_user):
"""Token with wrong issuer should raise TokenInvalidError."""
service_a = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer-a",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
service_b = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer-b",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
result = service_a.generate_jwt(mock_user, scope="read")
with pytest.raises(TokenInvalidError):
service_b.decode_jwt(result["access_token"])
def test_decode_jwt_wrong_audience_raises_token_invalid_error(mock_user):
"""Token with wrong audience should raise TokenInvalidError."""
service_a = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience-a",
expiration_seconds=3600,
token_type="Bearer",
)
service_b = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience-b",
expiration_seconds=3600,
token_type="Bearer",
)
result = service_a.generate_jwt(mock_user, scope="read")
with pytest.raises(TokenInvalidError):
service_b.decode_jwt(result["access_token"])
def test_decode_jwt_tampered_signature_raises_token_decode_error(
jwt_service, mock_user
):
"""Token with tampered signature should raise TokenDecodeError."""
result = jwt_service.generate_jwt(mock_user, scope="read")
header, payload, _ = result["access_token"].split(".")
tampered_token = f"{header}.{payload}.invalidsignature"
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt(tampered_token)
def test_decode_jwt_garbage_string_raises_token_decode_error(jwt_service):
"""Garbage string should raise TokenDecodeError."""
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt("this.is.not.a.valid.token")
def test_decode_jwt_empty_string_raises_token_decode_error(jwt_service):
"""Empty string should raise TokenDecodeError."""
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt("")
def test_decode_jwt_none_raises_token_decode_error(jwt_service):
"""None should raise TokenDecodeError."""
with pytest.raises(TokenDecodeError):
jwt_service.decode_jwt(None)
def test_algorithm_mismatch_raises_token_decode_error(mock_user):
"""Token encoded with HS256 decoded expecting RS256 should raise TokenDecodeError."""
service_hs256 = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="HS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
service_rs256 = JwtTokenService(
secret_key="test-secret-padded-to-32-bytes!!",
algorithm="RS256",
issuer="issuer",
audience="audience",
expiration_seconds=3600,
token_type="Bearer",
)
result = service_hs256.generate_jwt(mock_user, scope="read")
with pytest.raises(TokenDecodeError):
service_rs256.decode_jwt(result["access_token"])
@@ -72,7 +72,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
mock_api = create_mock_livekit_client() mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock( mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500) side_effect=TwirpError(msg="Internal server error", code=500, status=500)
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
@@ -177,7 +177,7 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
mock_api = create_mock_livekit_client() mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock( mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500) side_effect=TwirpError(msg="Internal server error", code=500, status=500)
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
@@ -270,7 +270,7 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
if call_count == 0: if call_count == 0:
call_count += 1 call_count += 1
return None return None
raise TwirpError(msg="Deletion failed", code="unknown", status=500) raise TwirpError(msg="Deletion failed", code=500, status=500)
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock( mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=delete_side_effect side_effect=delete_side_effect
@@ -294,7 +294,7 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
mock_list_rules.return_value = ["rule-1"] mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client() mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock( mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500) side_effect=TwirpError(msg="Internal server error", code=500, status=500)
) )
mock_client_factory.return_value = mock_api mock_client_factory.return_value = mock_api
@@ -713,7 +713,7 @@ def test_api_rooms_token_invalid_signature(mock_rs_authenticate, settings):
} }
token = jwt.encode( token = jwt.encode(
payload, payload,
"invalid-private-key-padded-to-32b!", "invalid-private-key",
algorithm=settings.APPLICATION_JWT_ALG, algorithm=settings.APPLICATION_JWT_ALG,
) )
+45 -4
View File
@@ -66,6 +66,7 @@ def generate_token(
sources: Optional[List[str]] = None, sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False, is_admin_or_owner: bool = False,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> str: ) -> str:
"""Generate a LiveKit access token for a user in a specific room. """Generate a LiveKit access token for a user in a specific room.
@@ -92,11 +93,15 @@ def generate_token(
if sources is None: if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES sources = settings.LIVEKIT_DEFAULT_SOURCES
# In encrypted rooms, no one can change their name/metadata to prevent
# identity spoofing — the admin accepted them based on their declared identity.
can_update_metadata = encryption_mode == 'none'
video_grants = VideoGrants( video_grants = VideoGrants(
room=room, room=room,
room_join=True, room_join=True,
room_admin=is_admin_or_owner, room_admin=is_admin_or_owner,
can_update_own_metadata=False, can_update_own_metadata=can_update_metadata,
can_publish=bool(sources), can_publish=bool(sources),
can_publish_sources=sources, can_publish_sources=sources,
can_subscribe=True, can_subscribe=True,
@@ -112,6 +117,42 @@ def generate_token(
if color is None: if color is None:
color = generate_color(identity) color = generate_color(identity)
# Build participant attributes — these are server-signed in the JWT
# and visible to all participants in the room.
attributes = {
"color": color,
"room_admin": "true" if is_admin_or_owner else "false",
"is_authenticated": "true" if not user.is_anonymous else "false",
}
# Add identity info for authenticated users in encrypted rooms only.
#
# Email and suite_user_id are included in the JWT attributes for encrypted
# rooms because:
# - Email: allows admins to verify participant identity in the lobby and
# participant list (important for trust decisions in encrypted meetings)
# - suite_user_id: required for vault key exchange in advanced encryption
# (vaultClient.shareKeys needs the recipient's user ID)
#
# These attributes are NOT included in non-encrypted rooms because:
# - Non-encrypted rooms have no waiting room, so anonymous users can join
# freely and would see everyone's email via LiveKit signaling
# - LiveKit JWT attributes are immutable and broadcast to ALL participants
# equally — there is no way to show them only to authenticated users
# at the protocol level
# - The frontend additionally hides email from anonymous users in the UI,
# but this is defense-in-depth, not the primary protection
#
# Future improvement: serve email via a Django API endpoint that checks
# the requester's authentication, removing it from the JWT entirely.
# This would require the backend to call LiveKit's ListParticipants API
# to cross-reference identities with the user database.
if not user.is_anonymous and encryption_mode != 'none':
if user.email:
attributes["email"] = user.email
if user.sub:
attributes["suite_user_id"] = str(user.sub)
token = ( token = (
AccessToken( AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"], api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
@@ -120,9 +161,7 @@ def generate_token(
.with_grants(video_grants) .with_grants(video_grants)
.with_identity(identity) .with_identity(identity)
.with_name(username or default_username) .with_name(username or default_username)
.with_attributes( .with_attributes(attributes)
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
) )
return token.to_jwt() return token.to_jwt()
@@ -136,6 +175,7 @@ def generate_livekit_config(
color: Optional[str] = None, color: Optional[str] = None,
configuration: Optional[dict] = None, configuration: Optional[dict] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
encryption_mode: str = 'none',
) -> dict: ) -> dict:
"""Generate LiveKit configuration for room access. """Generate LiveKit configuration for room access.
@@ -168,6 +208,7 @@ def generate_livekit_config(
sources=sources, sources=sources,
is_admin_or_owner=is_admin_or_owner, is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=encryption_mode,
), ),
} }
+14 -9
View File
@@ -561,12 +561,12 @@ class Base(Configuration):
"returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None "returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None
) )
OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue(
default=["given_name", "usual_name"], default=["first_name", "last_name"],
environ_name="OIDC_USERINFO_FULLNAME_FIELDS", environ_name="OIDC_USERINFO_FULLNAME_FIELDS",
environ_prefix=None, environ_prefix=None,
) )
OIDC_USERINFO_SHORTNAME_FIELD = values.Value( OIDC_USERINFO_SHORTNAME_FIELD = values.Value(
default="given_name", default="first_name",
environ_name="OIDC_USERINFO_SHORTNAME_FIELD", environ_name="OIDC_USERINFO_SHORTNAME_FIELD",
environ_prefix=None, environ_prefix=None,
) )
@@ -808,6 +808,17 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
# End-to-end encryption settings
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
ENCRYPTION_VAULT_URL = values.Value(
None, environ_name="ENCRYPTION_VAULT_URL", environ_prefix=None
)
ENCRYPTION_INTERFACE_URL = values.Value(
None, environ_name="ENCRYPTION_INTERFACE_URL", environ_prefix=None
)
# External Applications # External Applications
APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue( APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue(
40, 40,
@@ -981,13 +992,7 @@ class Test(Base):
USE_SWAGGER = True USE_SWAGGER = True
EXTERNAL_API_ENABLED = True EXTERNAL_API_ENABLED = True
LIVEKIT_CONFIGURATION = { APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
"api_key": "devkey-padded-for-minimum-len!-livekit",
"api_secret": "secret-key-padded-for-minimum-len!-livekit",
"url": "http://127.0.0.1.nip.io:7880",
}
APPLICATION_JWT_SECRET_KEY = "secret-key-padded-for-minimum-len!-application" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc." APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True
+5 -5
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.14.0" version = "1.12.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
@@ -40,7 +40,7 @@ dependencies = [
"django-storages[s3]==1.14.6", "django-storages[s3]==1.14.6",
"django-timezone-field>=5.1", "django-timezone-field>=5.1",
"django-pydantic-field==0.5.4", "django-pydantic-field==0.5.4",
"django==5.2.13", "django==5.2.12",
"djangorestframework==3.16.1", "djangorestframework==3.16.1",
"drf_spectacular==0.29.0", "drf_spectacular==0.29.0",
"dockerflow==2026.3.4", "dockerflow==2026.3.4",
@@ -55,12 +55,12 @@ dependencies = [
"PyJWT==2.12.1", "PyJWT==2.12.1",
"python-frontmatter==1.1.0", "python-frontmatter==1.1.0",
"python-magic==0.4.27", "python-magic==0.4.27",
"requests==2.33.0", "requests==2.32.5",
"sentry-sdk==2.54.0", "sentry-sdk==2.54.0",
"whitenoise==6.12.0", "whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2", "mozilla-django-oidc==5.0.2",
"livekit-api==1.1.0", "livekit-api==1.1.0",
"aiohttp==3.13.4", "aiohttp==3.13.3",
] ]
[project.urls] [project.urls]
@@ -81,7 +81,7 @@ dev = [
"pylint<4.0.0", "pylint<4.0.0",
"pytest-cov==7.0.0", "pytest-cov==7.0.0",
"pytest-django==4.12.0", "pytest-django==4.12.0",
"pytest==9.0.3", "pytest==9.0.2",
"pytest-icdiff==0.9", "pytest-icdiff==0.9",
"pytest-xdist==3.8.0", "pytest-xdist==3.8.0",
"responses==0.26.0", "responses==0.26.0",
+303 -319
View File
@@ -13,7 +13,7 @@ wheels = [
[[package]] [[package]]
name = "aiohttp" name = "aiohttp"
version = "3.13.4" version = "3.13.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiohappyeyeballs" }, { name = "aiohappyeyeballs" },
@@ -24,59 +24,59 @@ dependencies = [
{ name = "propcache" }, { name = "propcache" },
{ name = "yarl" }, { name = "yarl" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/45/4a/064321452809dae953c1ed6e017504e72551a26b6f5708a5a80e4bf556ff/aiohttp-3.13.4.tar.gz", hash = "sha256:d97a6d09c66087890c2ab5d49069e1e570583f7ac0314ecf98294c1b6aaebd38", size = 7859748, upload-time = "2026-03-28T17:19:40.6Z" } sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/ac/892f4162df9b115b4758d615f32ec63d00f3084c705ff5526630887b9b42/aiohttp-3.13.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:63dd5e5b1e43b8fb1e91b79b7ceba1feba588b317d1edff385084fcc7a0a4538", size = 745744, upload-time = "2026-03-28T17:16:44.67Z" }, { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" },
{ url = "https://files.pythonhosted.org/packages/97/a9/c5b87e4443a2f0ea88cb3000c93a8fdad1ee63bffc9ded8d8c8e0d66efc6/aiohttp-3.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:746ac3cc00b5baea424dacddea3ec2c2702f9590de27d837aa67004db1eebc6e", size = 498178, upload-time = "2026-03-28T17:16:46.766Z" }, { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" },
{ url = "https://files.pythonhosted.org/packages/94/42/07e1b543a61250783650df13da8ddcdc0d0a5538b2bd15cef6e042aefc61/aiohttp-3.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bda8f16ea99d6a6705e5946732e48487a448be874e54a4f73d514660ff7c05d3", size = 498331, upload-time = "2026-03-28T17:16:48.9Z" }, { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" },
{ url = "https://files.pythonhosted.org/packages/20/d6/492f46bf0328534124772d0cf58570acae5b286ea25006900650f69dae0e/aiohttp-3.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b061e7b5f840391e3f64d0ddf672973e45c4cfff7a0feea425ea24e51530fc2", size = 1744414, upload-time = "2026-03-28T17:16:50.968Z" }, { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4d/e02627b2683f68051246215d2d62b2d2f249ff7a285e7a858dc47d6b6a14/aiohttp-3.13.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b252e8d5cd66184b570d0d010de742736e8a4fab22c58299772b0c5a466d4b21", size = 1719226, upload-time = "2026-03-28T17:16:53.173Z" }, { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" },
{ url = "https://files.pythonhosted.org/packages/7b/6c/5d0a3394dd2b9f9aeba6e1b6065d0439e4b75d41f1fb09a3ec010b43552b/aiohttp-3.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20af8aad61d1803ff11152a26146d8d81c266aa8c5aa9b4504432abb965c36a0", size = 1782110, upload-time = "2026-03-28T17:16:55.362Z" }, { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/0d/2d/c20791e3437700a7441a7edfb59731150322424f5aadf635602d1d326101/aiohttp-3.13.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:13a5cc924b59859ad2adb1478e31f410a7ed46e92a2a619d6d1dd1a63c1a855e", size = 1884809, upload-time = "2026-03-28T17:16:57.734Z" }, { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" },
{ url = "https://files.pythonhosted.org/packages/c8/94/d99dbfbd1924a87ef643833932eb2a3d9e5eee87656efea7d78058539eff/aiohttp-3.13.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:534913dfb0a644d537aebb4123e7d466d94e3be5549205e6a31f72368980a81a", size = 1764938, upload-time = "2026-03-28T17:17:00.221Z" }, { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" },
{ url = "https://files.pythonhosted.org/packages/49/61/3ce326a1538781deb89f6cf5e094e2029cd308ed1e21b2ba2278b08426f6/aiohttp-3.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:320e40192a2dcc1cf4b5576936e9652981ab596bf81eb309535db7e2f5b5672f", size = 1570697, upload-time = "2026-03-28T17:17:02.985Z" }, { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/77/4ab5a546857bb3028fbaf34d6eea180267bdab022ee8b1168b1fcde4bfdd/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9e587fcfce2bcf06526a43cb705bdee21ac089096f2e271d75de9c339db3100c", size = 1702258, upload-time = "2026-03-28T17:17:05.28Z" }, { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" },
{ url = "https://files.pythonhosted.org/packages/79/63/d8f29021e39bc5af8e5d5e9da1b07976fb9846487a784e11e4f4eeda4666/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9eb9c2eea7278206b5c6c1441fdd9dc420c278ead3f3b2cc87f9b693698cc500", size = 1740287, upload-time = "2026-03-28T17:17:07.712Z" }, { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" },
{ url = "https://files.pythonhosted.org/packages/55/3a/cbc6b3b124859a11bc8055d3682c26999b393531ef926754a3445b99dfef/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:29be00c51972b04bf9d5c8f2d7f7314f48f96070ca40a873a53056e652e805f7", size = 1753011, upload-time = "2026-03-28T17:17:10.053Z" }, { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" },
{ url = "https://files.pythonhosted.org/packages/e0/30/836278675205d58c1368b21520eab9572457cf19afd23759216c04483048/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90c06228a6c3a7c9f776fe4fc0b7ff647fffd3bed93779a6913c804ae00c1073", size = 1566359, upload-time = "2026-03-28T17:17:12.433Z" }, { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" },
{ url = "https://files.pythonhosted.org/packages/50/b4/8032cc9b82d17e4277704ba30509eaccb39329dc18d6a35f05e424439e32/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a533ec132f05fd9a1d959e7f34184cd7d5e8511584848dab85faefbaac573069", size = 1785537, upload-time = "2026-03-28T17:17:14.721Z" }, { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" },
{ url = "https://files.pythonhosted.org/packages/17/7d/5873e98230bde59f493bf1f7c3e327486a4b5653fa401144704df5d00211/aiohttp-3.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1c946f10f413836f82ea4cfb90200d2a59578c549f00857e03111cf45ad01ca5", size = 1740752, upload-time = "2026-03-28T17:17:17.387Z" }, { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f2/13e46e0df051494d7d3c68b7f72d071f48c384c12716fc294f75d5b1a064/aiohttp-3.13.4-cp313-cp313-win32.whl", hash = "sha256:48708e2706106da6967eff5908c78ca3943f005ed6bcb75da2a7e4da94ef8c70", size = 433187, upload-time = "2026-03-28T17:17:19.523Z" }, { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" },
{ url = "https://files.pythonhosted.org/packages/ea/c0/649856ee655a843c8f8664592cfccb73ac80ede6a8c8db33a25d810c12db/aiohttp-3.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:74a2eb058da44fa3a877a49e2095b591d4913308bb424c418b77beb160c55ce3", size = 459778, upload-time = "2026-03-28T17:17:21.964Z" }, { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
{ url = "https://files.pythonhosted.org/packages/6d/29/6657cc37ae04cacc2dbf53fb730a06b6091cc4cbe745028e047c53e6d840/aiohttp-3.13.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:e0a2c961fc92abeff61d6444f2ce6ad35bb982db9fc8ff8a47455beacf454a57", size = 749363, upload-time = "2026-03-28T17:17:24.044Z" }, { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/30ccdf67ca3d24b610067dc63d64dcb91e5d88e27667811640644aa4a85d/aiohttp-3.13.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:153274535985a0ff2bff1fb6c104ed547cec898a09213d21b0f791a44b14d933", size = 499317, upload-time = "2026-03-28T17:17:26.199Z" }, { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" },
{ url = "https://files.pythonhosted.org/packages/93/13/e372dd4e68ad04ee25dafb050c7f98b0d91ea643f7352757e87231102555/aiohttp-3.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:351f3171e2458da3d731ce83f9e6b9619e325c45cbd534c7759750cabf453ad7", size = 500477, upload-time = "2026-03-28T17:17:28.279Z" }, { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" },
{ url = "https://files.pythonhosted.org/packages/e5/fe/ee6298e8e586096fb6f5eddd31393d8544f33ae0792c71ecbb4c2bef98ac/aiohttp-3.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f989ac8bc5595ff761a5ccd32bdb0768a117f36dd1504b1c2c074ed5d3f4df9c", size = 1737227, upload-time = "2026-03-28T17:17:30.587Z" }, { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/b0/b9/a7a0463a09e1a3fe35100f74324f23644bfc3383ac5fd5effe0722a5f0b7/aiohttp-3.13.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d36fc1709110ec1e87a229b201dd3ddc32aa01e98e7868083a794609b081c349", size = 1694036, upload-time = "2026-03-28T17:17:33.29Z" }, { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" },
{ url = "https://files.pythonhosted.org/packages/57/7c/8972ae3fb7be00a91aee6b644b2a6a909aedb2c425269a3bfd90115e6f8f/aiohttp-3.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42adaeea83cbdf069ab94f5103ce0787c21fb1a0153270da76b59d5578302329", size = 1786814, upload-time = "2026-03-28T17:17:36.035Z" }, { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/c81e97e85c774decbaf0d577de7d848934e8166a3a14ad9f8aa5be329d28/aiohttp-3.13.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:92deb95469928cc41fd4b42a95d8012fa6df93f6b1c0a83af0ffbc4a5e218cde", size = 1866676, upload-time = "2026-03-28T17:17:38.441Z" }, { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/5b46fe8694a639ddea2cd035bf5729e4677ea882cb251396637e2ef1590d/aiohttp-3.13.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0c7c07c4257ef3a1df355f840bc62d133bcdef5c1c5ba75add3c08553e2eed", size = 1740842, upload-time = "2026-03-28T17:17:40.783Z" }, { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" },
{ url = "https://files.pythonhosted.org/packages/20/a2/0d4b03d011cca6b6b0acba8433193c1e484efa8d705ea58295590fe24203/aiohttp-3.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f062c45de8a1098cb137a1898819796a2491aec4e637a06b03f149315dff4d8f", size = 1566508, upload-time = "2026-03-28T17:17:43.235Z" }, { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" },
{ url = "https://files.pythonhosted.org/packages/98/17/e689fd500da52488ec5f889effd6404dece6a59de301e380f3c64f167beb/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:76093107c531517001114f0ebdb4f46858ce818590363e3e99a4a2280334454a", size = 1700569, upload-time = "2026-03-28T17:17:46.165Z" }, { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" },
{ url = "https://files.pythonhosted.org/packages/d8/0d/66402894dbcf470ef7db99449e436105ea862c24f7ea4c95c683e635af35/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6f6ec32162d293b82f8b63a16edc80769662fbd5ae6fbd4936d3206a2c2cc63b", size = 1707407, upload-time = "2026-03-28T17:17:48.825Z" }, { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" },
{ url = "https://files.pythonhosted.org/packages/2f/eb/af0ab1a3650092cbd8e14ef29e4ab0209e1460e1c299996c3f8288b3f1ff/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5903e2db3d202a00ad9f0ec35a122c005e85d90c9836ab4cda628f01edf425e2", size = 1752214, upload-time = "2026-03-28T17:17:51.206Z" }, { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bf/72326f8a98e4c666f292f03c385545963cc65e358835d2a7375037a97b57/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2d5bea57be7aca98dbbac8da046d99b5557c5cf4e28538c4c786313078aca09e", size = 1562162, upload-time = "2026-03-28T17:17:53.634Z" }, { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" },
{ url = "https://files.pythonhosted.org/packages/67/9f/13b72435f99151dd9a5469c96b3b5f86aa29b7e785ca7f35cf5e538f74c0/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bcf0c9902085976edc0232b75006ef38f89686901249ce14226b6877f88464fb", size = 1768904, upload-time = "2026-03-28T17:17:55.991Z" }, { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" },
{ url = "https://files.pythonhosted.org/packages/18/bc/28d4970e7d5452ac7776cdb5431a1164a0d9cf8bd2fffd67b4fb463aa56d/aiohttp-3.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3295f98bfeed2e867cab588f2a146a9db37a85e3ae9062abf46ba062bd29165", size = 1723378, upload-time = "2026-03-28T17:17:58.348Z" }, { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" },
{ url = "https://files.pythonhosted.org/packages/53/74/b32458ca1a7f34d65bdee7aef2036adbe0438123d3d53e2b083c453c24dd/aiohttp-3.13.4-cp314-cp314-win32.whl", hash = "sha256:a598a5c5767e1369d8f5b08695cab1d8160040f796c4416af76fd773d229b3c9", size = 438711, upload-time = "2026-03-28T17:18:00.728Z" }, { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" },
{ url = "https://files.pythonhosted.org/packages/40/b2/54b487316c2df3e03a8f3435e9636f8a81a42a69d942164830d193beb56a/aiohttp-3.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:c555db4bc7a264bead5a7d63d92d41a1122fcd39cc62a4db815f45ad46f9c2c8", size = 464977, upload-time = "2026-03-28T17:18:03.367Z" }, { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/47/fb/e41b63c6ce71b07a59243bb8f3b457ee0c3402a619acb9d2c0d21ef0e647/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45abbbf09a129825d13c18c7d3182fecd46d9da3cfc383756145394013604ac1", size = 781549, upload-time = "2026-03-28T17:18:05.779Z" }, { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" },
{ url = "https://files.pythonhosted.org/packages/97/53/532b8d28df1e17e44c4d9a9368b78dcb6bf0b51037522136eced13afa9e8/aiohttp-3.13.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:74c80b2bc2c2adb7b3d1941b2b60701ee2af8296fc8aad8b8bc48bc25767266c", size = 514383, upload-time = "2026-03-28T17:18:08.096Z" }, { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1f/62e5d400603e8468cd635812d99cb81cfdc08127a3dc474c647615f31339/aiohttp-3.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c97989ae40a9746650fa196894f317dafc12227c808c774929dda0ff873a5954", size = 518304, upload-time = "2026-03-28T17:18:10.642Z" }, { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" },
{ url = "https://files.pythonhosted.org/packages/90/57/2326b37b10896447e3c6e0cbef4fe2486d30913639a5cfd1332b5d870f82/aiohttp-3.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dae86be9811493f9990ef44fff1685f5c1a3192e9061a71a109d527944eed551", size = 1893433, upload-time = "2026-03-28T17:18:13.121Z" }, { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b4/a24d82112c304afdb650167ef2fe190957d81cbddac7460bedd245f765aa/aiohttp-3.13.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1db491abe852ca2fa6cc48a3341985b0174b3741838e1341b82ac82c8bd9e871", size = 1755901, upload-time = "2026-03-28T17:18:16.21Z" }, { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" },
{ url = "https://files.pythonhosted.org/packages/9e/2d/0883ef9d878d7846287f036c162a951968f22aabeef3ac97b0bea6f76d5d/aiohttp-3.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e5d701c0aad02a7dce72eef6b93226cf3734330f1a31d69ebbf69f33b86666e", size = 1876093, upload-time = "2026-03-28T17:18:18.703Z" }, { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/9204bb59c014869b71971addad6778f005daa72a96eed652c496789d7468/aiohttp-3.13.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ac32a189081ae0a10ba18993f10f338ec94341f0d5df8fff348043962f3c6f8", size = 1970815, upload-time = "2026-03-28T17:18:21.858Z" }, { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b5/e4eb20275a866dde0f570f411b36c6b48f7b53edfe4f4071aa1b0728098a/aiohttp-3.13.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98e968cdaba43e45c73c3f306fca418c8009a957733bac85937c9f9cf3f4de27", size = 1816223, upload-time = "2026-03-28T17:18:24.729Z" }, { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/d8/23/e98075c5bb146aa61a1239ee1ac7714c85e814838d6cebbe37d3fe19214a/aiohttp-3.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca114790c9144c335d538852612d3e43ea0f075288f4849cf4b05d6cd2238ce7", size = 1649145, upload-time = "2026-03-28T17:18:27.269Z" }, { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" },
{ url = "https://files.pythonhosted.org/packages/d6/c1/7bad8be33bb06c2bb224b6468874346026092762cbec388c3bdb65a368ee/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ea2e071661ba9cfe11eabbc81ac5376eaeb3061f6e72ec4cc86d7cdd1ffbdbbb", size = 1816562, upload-time = "2026-03-28T17:18:29.847Z" }, { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/c00323348695e9a5e316825969c88463dcc24c7e9d443244b8a2c9cf2eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:34e89912b6c20e0fd80e07fa401fd218a410aa1ce9f1c2f1dad6db1bd0ce0927", size = 1800333, upload-time = "2026-03-28T17:18:32.269Z" }, { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/84/43/9b2147a1df3559f49bd723e22905b46a46c068a53adb54abdca32c4de180/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0e217cf9f6a42908c52b46e42c568bd57adc39c9286ced31aaace614b6087965", size = 1820617, upload-time = "2026-03-28T17:18:35.238Z" }, { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" },
{ url = "https://files.pythonhosted.org/packages/a9/7f/b3481a81e7a586d02e99387b18c6dafff41285f6efd3daa2124c01f87eae/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c296f1221e21ba979f5ac1964c3b78cfde15c5c5f855ffd2caab337e9cd9182", size = 1643417, upload-time = "2026-03-28T17:18:37.949Z" }, { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" },
{ url = "https://files.pythonhosted.org/packages/8f/72/07181226bc99ce1124e0f89280f5221a82d3ae6a6d9d1973ce429d48e52b/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d99a9d168ebaffb74f36d011750e490085ac418f4db926cce3989c8fe6cb6b1b", size = 1849286, upload-time = "2026-03-28T17:18:40.534Z" }, { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" },
{ url = "https://files.pythonhosted.org/packages/1a/e6/1b3566e103eca6da5be4ae6713e112a053725c584e96574caf117568ffef/aiohttp-3.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb19177205d93b881f3f89e6081593676043a6828f59c78c17a0fd6c1fbed2ba", size = 1782635, upload-time = "2026-03-28T17:18:43.073Z" }, { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" },
{ url = "https://files.pythonhosted.org/packages/37/58/1b11c71904b8d079eb0c39fe664180dd1e14bebe5608e235d8bfbadc8929/aiohttp-3.13.4-cp314-cp314t-win32.whl", hash = "sha256:c606aa5656dab6552e52ca368e43869c916338346bfaf6304e15c58fb113ea30", size = 472537, upload-time = "2026-03-28T17:18:46.286Z" }, { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" },
{ url = "https://files.pythonhosted.org/packages/bc/8f/87c56a1a1977d7dddea5b31e12189665a140fdb48a71e9038ff90bb564ec/aiohttp-3.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:014dcc10ec8ab8db681f0d68e939d1e9286a5aa2b993cbbdb0db130853e02144", size = 506381, upload-time = "2026-03-28T17:18:48.74Z" }, { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" },
] ]
[[package]] [[package]]
@@ -141,11 +141,11 @@ wheels = [
[[package]] [[package]]
name = "attrs" name = "attrs"
version = "26.1.0" version = "25.4.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
] ]
[[package]] [[package]]
@@ -173,16 +173,16 @@ wheels = [
[[package]] [[package]]
name = "botocore" name = "botocore"
version = "1.42.89" version = "1.42.73"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jmespath" }, { name = "jmespath" },
{ name = "python-dateutil" }, { name = "python-dateutil" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/0f/cc/e6be943efa9051bd15c2ee14077c2b10d6e27c9e9385fc43a03a5c4ed8b5/botocore-1.42.89.tar.gz", hash = "sha256:95ac52f472dad29942f3088b278ab493044516c16dbf9133c975af16527baa99", size = 15206290, upload-time = "2026-04-13T19:36:02.321Z" } sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" }, { url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" },
] ]
[[package]] [[package]]
@@ -306,71 +306,55 @@ wheels = [
[[package]] [[package]]
name = "charset-normalizer" name = "charset-normalizer"
version = "3.4.7" version = "3.4.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
] ]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.2" version = "8.3.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
] ]
[[package]] [[package]]
@@ -421,124 +405,124 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.13.5" version = "7.13.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
] ]
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "46.0.7" version = "46.0.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
] ]
[[package]] [[package]]
@@ -573,16 +557,16 @@ wheels = [
[[package]] [[package]]
name = "django" name = "django"
version = "5.2.13" version = "5.2.12"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/1f/c5/c69e338eb2959f641045802e5ea87ca4bf5ac90c5fd08953ca10742fad51/django-5.2.13.tar.gz", hash = "sha256:a31589db5188d074c63f0945c3888fad104627dfcc236fb2b97f71f89da33bc4", size = 10890368, upload-time = "2026-04-07T14:02:15.072Z" } sdist = { url = "https://files.pythonhosted.org/packages/bd/55/b9445fc0695b03746f355c05b2eecc54c34e05198c686f4fc4406b722b52/django-5.2.12.tar.gz", hash = "sha256:6b809af7165c73eff5ce1c87fdae75d4da6520d6667f86401ecf55b681eb1eeb", size = 10860574, upload-time = "2026-03-03T13:56:05.509Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/59/b1/51ab36b2eefcf8cdb9338c7188668a157e29e30306bfc98a379704c9e10d/django-5.2.13-py3-none-any.whl", hash = "sha256:5788fce61da23788a8ce6f02583765ab060d396720924789f97fa42119d37f7a", size = 8310982, upload-time = "2026-04-07T14:02:08.883Z" }, { url = "https://files.pythonhosted.org/packages/4e/32/4b144e125678efccf5d5b61581de1c4088d6b0286e46096e3b8de0d556c8/django-5.2.12-py3-none-any.whl", hash = "sha256:4853482f395c3a151937f6991272540fcbf531464f254a347bf7c89f53c8cff7", size = 8310245, upload-time = "2026-03-03T13:56:01.174Z" },
] ]
[[package]] [[package]]
@@ -834,14 +818,14 @@ wheels = [
[[package]] [[package]]
name = "faker" name = "faker"
version = "40.13.0" version = "40.8.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/89/95/4822ffe94723553789aef783104f4f18fc20d7c4c68e1bbd633e11d09758/faker-40.13.0.tar.gz", hash = "sha256:a0751c84c3abac17327d7bb4c98e8afe70ebf7821e01dd7d0b15cd8856415525", size = 1962043, upload-time = "2026-04-06T16:44:55.68Z" } sdist = { url = "https://files.pythonhosted.org/packages/70/03/14428edc541467c460d363f6e94bee9acc271f3e62470630fc9a647d0cf2/faker-40.8.0.tar.gz", hash = "sha256:936a3c9be6c004433f20aa4d99095df5dec82b8c7ad07459756041f8c1728875", size = 1956493, upload-time = "2026-03-04T16:18:48.161Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/8a/708103325edff16a0b0e004de0d37db8ba216a32713948c64d71f6d4a4c2/faker-40.13.0-py3-none-any.whl", hash = "sha256:c1298fd0d819b3688fb5fd358c4ba8f56c7c8c740b411fd3dbd8e30bf2c05019", size = 1994597, upload-time = "2026-04-06T16:44:53.698Z" }, { url = "https://files.pythonhosted.org/packages/4c/3b/c6348f1e285e75b069085b18110a4e6325b763a5d35d5e204356fc7c20b3/faker-40.8.0-py3-none-any.whl", hash = "sha256:eb21bdba18f7a8375382eb94fb436fce07046893dc94cb20817d28deb0c3d579", size = 1989124, upload-time = "2026-03-04T16:18:46.45Z" },
] ]
[[package]] [[package]]
@@ -1055,14 +1039,14 @@ wheels = [
[[package]] [[package]]
name = "joserfc" name = "joserfc"
version = "1.6.4" version = "1.6.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/de/c6/de8fdbdfa75c8ca04fead38a82d573df8a82906e984c349d58665f459558/joserfc-1.6.4.tar.gz", hash = "sha256:34ce5f499bfcc5e9ad4cc75077f9278ab3227b71da9aaf28f9ab705f8a560d3c", size = 231866, upload-time = "2026-04-13T13:15:40.632Z" } sdist = { url = "https://files.pythonhosted.org/packages/ce/90/b8cc8635c4ce2e5e8104bf26ef147f6e599478f6329107283cdc53aae97f/joserfc-1.6.3.tar.gz", hash = "sha256:c00c2830db969b836cba197e830e738dd9dda0955f1794e55d3c636f17f5c9a6", size = 229090, upload-time = "2026-02-25T15:33:38.167Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/f7/210b27752e972edb36d239315b08d3eb6b14824cc4a590da2337d195260b/joserfc-1.6.4-py3-none-any.whl", hash = "sha256:3e4a22b509b41908989237a045e25c8308d5fd47ab96bdae2dd8057c6451003a", size = 70464, upload-time = "2026-04-13T13:15:39.259Z" }, { url = "https://files.pythonhosted.org/packages/12/4f/124b3301067b752f44f292f0b9a74e837dd75ff863ee39500a082fc4c733/joserfc-1.6.3-py3-none-any.whl", hash = "sha256:6beab3635358cbc565cb94fb4c53d0557e6d10a15b933e2134939351590bda9a", size = 70465, upload-time = "2026-02-25T15:33:36.997Z" },
] ]
[[package]] [[package]]
@@ -1130,15 +1114,15 @@ wheels = [
[[package]] [[package]]
name = "livekit-protocol" name = "livekit-protocol"
version = "1.1.5" version = "1.1.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "protobuf" }, { name = "protobuf" },
{ name = "types-protobuf" }, { name = "types-protobuf" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7d/49/6137f28bfdc6c161e4b3327a05e103cda30bf521151736dcbf8bbdb2a5ca/livekit_protocol-1.1.5.tar.gz", hash = "sha256:54af234c15819a6f8e2b51511325fcfb181fed49a04547a7dfcd9b67ccd03767", size = 93740, upload-time = "2026-04-08T21:20:00.937Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/6c/f5f7cb226441b3a357c2ea5444899b133dd13a5875894c6a9cd52fc5aa74/livekit_protocol-1.1.2.tar.gz", hash = "sha256:4550bf78fb9d365f19ea9875e565d86a2fb798854c8bd2e9100d7f7640dd9072", size = 79620, upload-time = "2026-01-20T01:27:23.437Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/cd/903f8a11a49332933ae2d5c34baa22ee2cb94f4426e25a3eff6d598ffb04/livekit_protocol-1.1.5-py3-none-any.whl", hash = "sha256:7d517ac2bab69a2b889f94ea65ae74be4f9db251d614513bbcf1e3d333008720", size = 114852, upload-time = "2026-04-08T21:19:59.341Z" }, { url = "https://files.pythonhosted.org/packages/55/f9/40e81d1b126d79a00b4a8a472a4f7c655b0a7736bb4d08f936be550b3bd8/livekit_protocol-1.1.2-py3-none-any.whl", hash = "sha256:8a26d592a87f5f70fee23aa88e47490727158ee8799c82742585aa8f73b160c5", size = 98854, upload-time = "2026-01-20T01:27:22.139Z" },
] ]
[[package]] [[package]]
@@ -1173,7 +1157,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.14.0" version = "1.12.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -1237,13 +1221,13 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiohttp", specifier = "==3.13.4" }, { name = "aiohttp", specifier = "==3.13.3" },
{ name = "boto3", specifier = "==1.42.68" }, { name = "boto3", specifier = "==1.42.68" },
{ name = "brevo-python", specifier = "==1.2.0" }, { name = "brevo-python", specifier = "==1.2.0" },
{ name = "brotli", specifier = "==1.2.0" }, { name = "brotli", specifier = "==1.2.0" },
{ name = "celery", extras = ["redis"], specifier = "==5.6.2" }, { name = "celery", extras = ["redis"], specifier = "==5.6.2" },
{ name = "dj-database-url", specifier = "==3.1.2" }, { name = "dj-database-url", specifier = "==3.1.2" },
{ name = "django", specifier = "==5.2.13" }, { name = "django", specifier = "==5.2.12" },
{ name = "django-configurations", specifier = "==2.5.1" }, { name = "django-configurations", specifier = "==2.5.1" },
{ name = "django-cors-headers", specifier = "==4.9.0" }, { name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-countries", specifier = "==8.2.0" }, { name = "django-countries", specifier = "==8.2.0" },
@@ -1271,7 +1255,7 @@ requires-dist = [
{ name = "python-frontmatter", specifier = "==1.1.0" }, { name = "python-frontmatter", specifier = "==1.1.0" },
{ name = "python-magic", specifier = "==0.4.27" }, { name = "python-magic", specifier = "==0.4.27" },
{ name = "redis", specifier = "==5.2.1" }, { name = "redis", specifier = "==5.2.1" },
{ name = "requests", specifier = "==2.33.0" }, { name = "requests", specifier = "==2.32.5" },
{ name = "sentry-sdk", specifier = "==2.54.0" }, { name = "sentry-sdk", specifier = "==2.54.0" },
{ name = "whitenoise", specifier = "==6.12.0" }, { name = "whitenoise", specifier = "==6.12.0" },
] ]
@@ -1286,7 +1270,7 @@ dev = [
{ name = "pyfakefs", specifier = "==6.1.5" }, { name = "pyfakefs", specifier = "==6.1.5" },
{ name = "pylint", specifier = "<4.0.0" }, { name = "pylint", specifier = "<4.0.0" },
{ name = "pylint-django", specifier = "==2.7.0" }, { name = "pylint-django", specifier = "==2.7.0" },
{ name = "pytest", specifier = "==9.0.3" }, { name = "pytest", specifier = "==9.0.2" },
{ name = "pytest-cov", specifier = "==7.0.0" }, { name = "pytest-cov", specifier = "==7.0.0" },
{ name = "pytest-django", specifier = "==4.12.0" }, { name = "pytest-django", specifier = "==4.12.0" },
{ name = "pytest-icdiff", specifier = "==0.9" }, { name = "pytest-icdiff", specifier = "==0.9" },
@@ -1400,11 +1384,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/b8/48/b91a6f924a2abd9e8
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "26.1" version = "26.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]] [[package]]
@@ -1430,69 +1414,69 @@ wheels = [
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.2.0" version = "12.1.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
] ]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.9.6" version = "4.9.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
] ]
[[package]] [[package]]
@@ -1596,17 +1580,17 @@ wheels = [
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "7.34.1" version = "7.34.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } sdist = { url = "https://files.pythonhosted.org/packages/f2/00/04a2ab36b70a52d0356852979e08b44edde0435f2115dc66e25f2100f3ab/protobuf-7.34.0.tar.gz", hash = "sha256:3871a3df67c710aaf7bb8d214cc997342e63ceebd940c8c7fc65c9b3d697591a", size = 454726, upload-time = "2026-02-27T00:30:25.421Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, { url = "https://files.pythonhosted.org/packages/13/c4/6322ab5c8f279c4c358bc14eb8aefc0550b97222a39f04eb3c1af7a830fa/protobuf-7.34.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:8e329966799f2c271d5e05e236459fe1cbfdb8755aaa3b0914fa60947ddea408", size = 429248, upload-time = "2026-02-27T00:30:14.924Z" },
{ url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, { url = "https://files.pythonhosted.org/packages/45/99/b029bbbc61e8937545da5b79aa405ab2d9cf307a728f8c9459ad60d7a481/protobuf-7.34.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:9d7a5005fb96f3c1e64f397f91500b0eb371b28da81296ae73a6b08a5b76cdd6", size = 325753, upload-time = "2026-02-27T00:30:17.247Z" },
{ url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, { url = "https://files.pythonhosted.org/packages/cc/79/09f02671eb75b251c5550a1c48e7b3d4b0623efd7c95a15a50f6f9fc1e2e/protobuf-7.34.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:4a72a8ec94e7a9f7ef7fe818ed26d073305f347f8b3b5ba31e22f81fd85fca02", size = 340200, upload-time = "2026-02-27T00:30:18.672Z" },
{ url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, { url = "https://files.pythonhosted.org/packages/b5/57/89727baef7578897af5ed166735ceb315819f1c184da8c3441271dbcfde7/protobuf-7.34.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:964cf977e07f479c0697964e83deda72bcbc75c3badab506fb061b352d991b01", size = 324268, upload-time = "2026-02-27T00:30:20.088Z" },
{ url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, { url = "https://files.pythonhosted.org/packages/1f/3e/38ff2ddee5cc946f575c9d8cc822e34bde205cf61acf8099ad88ef19d7d2/protobuf-7.34.0-cp310-abi3-win32.whl", hash = "sha256:f791ec509707a1d91bd02e07df157e75e4fb9fbdad12a81b7396201ec244e2e3", size = 426628, upload-time = "2026-02-27T00:30:21.555Z" },
{ url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, { url = "https://files.pythonhosted.org/packages/cb/71/7c32eaf34a61a1bae1b62a2ac4ffe09b8d1bb0cf93ad505f42040023db89/protobuf-7.34.0-cp310-abi3-win_amd64.whl", hash = "sha256:9f9079f1dde4e32342ecbd1c118d76367090d4aaa19da78230c38101c5b3dd40", size = 437901, upload-time = "2026-02-27T00:30:22.836Z" },
{ url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, { url = "https://files.pythonhosted.org/packages/a4/e7/14dc9366696dcb53a413449881743426ed289d687bcf3d5aee4726c32ebb/protobuf-7.34.0-py3-none-any.whl", hash = "sha256:e3b914dd77fa33fa06ab2baa97937746ab25695f389869afdf03e81f34e45dc7", size = 170716, upload-time = "2026-02-27T00:30:23.994Z" },
] ]
[[package]] [[package]]
@@ -1761,11 +1745,11 @@ wheels = [
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.20.0" version = "2.19.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
] ]
[[package]] [[package]]
@@ -1821,7 +1805,7 @@ wheels = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.3" version = "9.0.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1830,9 +1814,9 @@ dependencies = [
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pygments" }, { name = "pygments" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
] ]
[[package]] [[package]]
@@ -1981,7 +1965,7 @@ wheels = [
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.33.0" version = "2.32.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "certifi" }, { name = "certifi" },
@@ -1989,9 +1973,9 @@ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "urllib3" }, { name = "urllib3" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
] ]
[[package]] [[package]]
@@ -2188,11 +2172,11 @@ wheels = [
[[package]] [[package]]
name = "types-protobuf" name = "types-protobuf"
version = "7.34.1.20260408" version = "6.32.1.20260221"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/b1/4521e68c2cc17703d80eb42796751345376dd4c706f84007ef5e7c707774/types_protobuf-7.34.1.20260408.tar.gz", hash = "sha256:e2c0a0430e08c75b52671a6f0035abfdcc791aad12af16274282de1b721758ab", size = 68835, upload-time = "2026-04-08T04:26:43.613Z" } sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/b5/0bc9874d89c58fb0ce851e150055ce732d254dbb10b06becbc7635d0d635/types_protobuf-7.34.1.20260408-py3-none-any.whl", hash = "sha256:ebbcd4e27b145aef6a59bc0cb6c013b3528151c1ba5e7f7337aeee355d276a5e", size = 86012, upload-time = "2026-04-08T04:26:42.566Z" }, { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" },
] ]
[[package]] [[package]]
@@ -2230,11 +2214,11 @@ wheels = [
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.1" version = "2025.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
] ]
[[package]] [[package]]
+8 -12
View File
@@ -1,7 +1,5 @@
FROM node:20-alpine AS frontend-deps FROM node:20-alpine AS frontend-deps
USER node
WORKDIR /home/frontend/ WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json COPY ./src/frontend/package.json ./package.json
@@ -10,7 +8,7 @@ COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci RUN npm ci
COPY .dockerignore ./.dockerignore COPY .dockerignore ./.dockerignore
COPY --chown=node:node ./src/frontend/ . COPY ./src/frontend/ .
### ---- Front-end builder image ---- ### ---- Front-end builder image ----
FROM frontend-deps AS meet FROM frontend-deps AS meet
@@ -19,8 +17,6 @@ WORKDIR /home/frontend
FROM frontend-deps AS meet-dev FROM frontend-deps AS meet-dev
USER node
WORKDIR /home/frontend WORKDIR /home/frontend
EXPOSE 8080 EXPOSE 8080
@@ -39,15 +35,15 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build RUN npm run build
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root USER root
RUN apk update && apk upgrade libssl3 \
# Security patches for known CVEs libcrypto3 \
RUN apk update && apk upgrade \ libxml2>=2.12.7-r2 \
musl \ libxslt>=1.1.39-r2 \
musl-utils \ libexpat>=2.7.2-r0 \
zlib>=1.3.2-r0 \ libpng>=1.6.53-r0 \
&& apk del curl && apk del curl
USER nginx USER nginx
+986 -117
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.14.0", "version": "1.12.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -10,7 +10,9 @@
"preview": "vite preview", "preview": "vite preview",
"i18n:extract": "npx i18next -c i18next-parser.config.json", "i18n:extract": "npx i18next -c i18next-parser.config.json",
"format": "prettier --write ./src", "format": "prettier --write ./src",
"check": "prettier --check ./src" "check": "prettier --check ./src",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/material-symbols-outlined": "5.2.34", "@fontsource-variable/material-symbols-outlined": "5.2.34",
@@ -59,10 +61,12 @@
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20", "eslint-plugin-react-refresh": "0.4.20",
"jsdom": "^29.0.2",
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.8.1", "prettier": "3.8.1",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "7.3.2", "vite": "7.3.1",
"vite-tsconfig-paths": "6.1.1" "vite-tsconfig-paths": "6.1.1",
"vitest": "^4.1.3"
} }
} }
-5
View File
@@ -274,11 +274,6 @@ const config: Config = {
min: { value: 'min-content' }, min: { value: 'min-content' },
max: { value: 'max-content' }, max: { value: 'max-content' },
fit: { value: 'fit-content' }, fit: { value: 'fit-content' },
// room layout
'room-side-panel': { value: '360px' },
'room-side-panel-margin': { value: '1.5rem' },
'room-control-bar': { value: '80px' },
'room-reaction-toolbar-height': { value: '42px' },
}, },
spacing, spacing,
}), }),
+11 -8
View File
@@ -14,6 +14,7 @@ import './i18n/init'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { AppInitialization } from '@/components/AppInitialization' import { AppInitialization } from '@/components/AppInitialization'
import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext' import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
import { VaultClientProvider } from '@/features/encryption'
function App() { function App() {
const { i18n } = useTranslation() const { i18n } = useTranslation()
@@ -25,20 +26,22 @@ function App() {
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{!isSDKContext && <AppInitialization />} {!isSDKContext && <AppInitialization />}
<Suspense fallback={null}> <Suspense fallback={null}>
<I18nProvider locale={i18n.language}> <VaultClientProvider>
<Layout> <I18nProvider locale={i18n.language}>
<Layout>
<Switch> <Switch>
{Object.entries(routes).map(([, route], i) => ( {Object.entries(routes).map(([, route], i) => (
<Route key={i} path={route.path} component={route.Component} /> <Route key={i} path={route.path} component={route.Component} />
))} ))}
<Route component={NotFoundScreen} /> <Route component={NotFoundScreen} />
</Switch> </Switch>
</Layout> </Layout>
<ReactQueryDevtools <ReactQueryDevtools
initialIsOpen={false} initialIsOpen={false}
buttonPosition="bottom-left" buttonPosition="bottom-left"
/> />
</I18nProvider> </I18nProvider>
</VaultClientProvider>
</Suspense> </Suspense>
</QueryClientProvider> </QueryClientProvider>
) )
+5
View File
@@ -52,6 +52,11 @@ export interface ApiConfig {
enable_firefox_proxy_workaround: boolean enable_firefox_proxy_workaround: boolean
default_sources: string[] default_sources: string[]
} }
encryption?: {
enabled: boolean
vault_url: string
interface_url: string
}
transcription_destination?: string transcription_destination?: string
} }
+2 -2
View File
@@ -57,7 +57,7 @@ export const Avatar = ({
style, style,
...props ...props
}: AvatarProps) => { }: AvatarProps) => {
const initial = name?.trim()?.charAt(0) ?? '' const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? ''
return ( return (
<div <div
style={{ style={{
@@ -70,7 +70,7 @@ export const Avatar = ({
<span <span
aria-hidden="true" aria-hidden="true"
className={css({ className={css({
marginTop: '-0.3rem', lineHeight: 1,
})} })}
> >
{initial} {initial}
@@ -4,5 +4,4 @@ export enum FeatureFlags {
faceLandmarks = 'face-landmarks', faceLandmarks = 'face-landmarks',
noiseReduction = 'noise-reduction', noiseReduction = 'noise-reduction',
subtitles = 'subtitles', subtitles = 'subtitles',
candidatePolling = 'candidate-polling',
} }
@@ -3,7 +3,8 @@ import { BackendLanguage } from '@/utils/languages'
export type ApiUser = { export type ApiUser = {
id: string id: string
email: string email: string
full_name: string full_name: string | null
short_name: string | null
last_name: string last_name: string
language: BackendLanguage language: BackendLanguage
timezone: string timezone: string
@@ -0,0 +1,181 @@
/**
* Indicator shown at the top-left of an encrypted meeting.
*
* Initially shows the full label "End-to-end encrypted" with a lock icon.
* After a few seconds, collapses to just the lock icon.
* On hover, expands back with a smooth animation.
* Clicking opens a modal explaining what E2EE means and its limitations.
*/
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { RiLockFill, RiShieldCheckFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useEffect, useState } from 'react'
import { Dialog, Text } from '@/primitives'
const COLLAPSE_DELAY = 4000
export function EncryptedMeetingBanner() {
const roomData = useRoomData()
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
const [isCollapsed, setIsCollapsed] = useState(false)
const [isModalOpen, setIsModalOpen] = useState(false)
const isStrongEncryption = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
useEffect(() => {
const timer = setTimeout(() => setIsCollapsed(true), COLLAPSE_DELAY)
return () => clearTimeout(timer)
}, [])
if (!isEncryptedRoom(roomData)) return null
const bgColor = isStrongEncryption ? '#166534' : '#1e3a5f'
const hoverBgColor = isStrongEncryption ? '#15803d' : '#2563eb'
const icon = isStrongEncryption
? <RiShieldCheckFill size={13} color="white" className={css({ flexShrink: 0 })} />
: <RiLockFill size={13} color="white" className={css({ flexShrink: 0 })} />
const label = isStrongEncryption ? t('bannerStrong') : t('banner')
return (
<>
<div
onMouseEnter={() => setIsCollapsed(false)}
onMouseLeave={() => setIsCollapsed(true)}
onClick={() => setIsModalOpen(true)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && setIsModalOpen(true)}
aria-label={label}
className={css({
position: 'absolute',
top: '0.5rem',
left: '0.5rem',
zIndex: 10,
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
padding: '0.3rem 0.6rem',
borderRadius: '1rem',
border: '2px solid rgba(0, 0, 0, 0.3)',
cursor: 'pointer',
overflow: 'hidden',
transition: 'all 300ms ease',
maxWidth: isCollapsed ? '2.2rem' : '16rem',
whiteSpace: 'nowrap',
})}
style={{
backgroundColor: bgColor,
paddingRight: isCollapsed ? '0.3rem' : '0.6rem',
}}
onMouseOver={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = hoverBgColor }}
onMouseOut={(e) => { (e.currentTarget as HTMLElement).style.backgroundColor = bgColor }}
>
{icon}
<span
className={css({
fontSize: '0.7rem',
fontWeight: 600,
color: 'white',
letterSpacing: '0.02em',
transition: 'opacity 200ms ease',
})}
style={{
opacity: isCollapsed ? 0 : 1,
}}
>
{label}
</span>
</div>
<Dialog
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
role="dialog"
type="flex"
title={t('bannerModal.title')}
>
<VStack
gap="1rem"
alignItems="start"
className={css({ maxWidth: '24rem' })}
>
<Text variant="sm">
{isStrongEncryption
? t('bannerModal.descriptionAdvanced')
: t('bannerModal.descriptionBasic')}
</Text>
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
<Text variant="sm" className={css({ fontWeight: 600 })}>
{t('bannerModal.guarantees')}
</Text>
<ul
className={css({
paddingLeft: '1.5rem',
fontSize: '0.85rem',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
'& li': {
paddingLeft: '0.25rem',
},
'& li::marker': {
color: '#22c55e',
},
})}
>
<li>{t('bannerModal.guarantee1')}</li>
<li>{t('bannerModal.guarantee2')}</li>
<li>{t('bannerModal.guarantee3')}</li>
</ul>
</VStack>
<VStack gap="0.5rem" alignItems="start" className={css({ width: '100%' })}>
<Text variant="sm" className={css({ fontWeight: 600 })}>
{t('bannerModal.limitations')}
</Text>
<ul
className={css({
paddingLeft: '1.5rem',
fontSize: '0.85rem',
listStyleType: 'disc',
display: 'flex',
flexDirection: 'column',
gap: '0.4rem',
'& li': {
paddingLeft: '0.25rem',
},
'& li::marker': {
color: '#f59e0b',
},
})}
>
<li>{t('bannerModal.limitation1')}</li>
<li>{isStrongEncryption
? t('bannerModal.limitation2Advanced')
: t('bannerModal.limitation2Basic')}
</li>
</ul>
</VStack>
<Text
variant="note"
className={css({
fontSize: '0.75rem',
borderTop: '1px solid',
borderColor: 'greyscale.200',
paddingTop: '0.75rem',
width: '100%',
})}
>
{t('bannerModal.note')}
</Text>
</VStack>
</Dialog>
</>
)
}
@@ -0,0 +1,82 @@
/**
* Per-participant encryption trust badge.
*
* In advanced mode:
* - "verified": Green shield fingerprint explicitly trusted
* - "unknown": Grey shield has public key, not yet verified
* - "refused": Red shield fingerprint previously refused
* - "authenticated": Blue shield ProConnect, no vault keys
* - "anonymous": Orange warning not signed in
*
* In basic mode:
* - "authenticated": Blue shield ProConnect
* - "anonymous": Orange warning not signed in
*/
import {
RiShieldCheckFill,
RiShieldFill,
RiShieldCrossFill,
RiErrorWarningFill,
RiLockFill,
} from '@remixicon/react'
import type { TrustLevel } from './types'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
interface EncryptionBadgeProps {
trustLevel: TrustLevel | null
isEncrypted: boolean
}
export function EncryptionBadge({
trustLevel,
isEncrypted,
}: EncryptionBadgeProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
if (!isEncrypted) return null
let icon: React.ReactNode
let label: string
switch (trustLevel) {
case 'verified':
icon = <RiShieldCheckFill size={14} color="#22c55e" />
label = t('verified')
break
case 'unknown':
icon = <RiShieldFill size={14} color="#9ca3af" />
label = t('unknown')
break
case 'refused':
icon = <RiShieldCrossFill size={14} color="#ef4444" />
label = t('refused')
break
case 'authenticated':
icon = <RiShieldCheckFill size={14} color="#3b82f6" />
label = t('authenticated')
break
case 'anonymous':
icon = <RiErrorWarningFill size={15} color="#d97706" />
label = t('anonymous')
break
default:
icon = <RiLockFill size={14} />
label = t('default')
break
}
return (
<span
aria-label={label}
className={css({
display: 'inline-flex',
alignItems: 'center',
marginRight: '0.15rem',
cursor: 'inherit',
})}
>
{icon}
</span>
)
}
@@ -0,0 +1,10 @@
import { createContext, useContext } from 'react'
interface EncryptionContextValue {
symmetricKey?: Uint8Array
}
const EncryptionContext = createContext<EncryptionContextValue>({})
export const EncryptionProvider = EncryptionContext.Provider
export const useEncryptionContext = () => useContext(EncryptionContext)
@@ -0,0 +1,326 @@
/**
* Dialog showing a participant's encryption fingerprint.
* Allows the admin to verify, accept, or refuse the fingerprint.
*
* This connects to the encryption library's VaultClient to check/accept/refuse
* fingerprints from the TOFU (Trust On First Use) registry.
*/
import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text, Button } from '@/primitives'
import { Avatar } from '@/components/Avatar'
import { useUser } from '@/features/auth'
import {
RiShieldCheckFill,
RiShieldCheckLine,
RiAlertLine,
RiCheckLine,
RiCloseLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useVaultClient } from './VaultClientProvider'
import { formatFingerprint } from './useParticipantTrustLevel'
import { useEffect, useState } from 'react'
interface EncryptionIdentityDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
participantName: string
participantEmail?: string
suiteUserId?: string
isAuthenticated: boolean
encryptionMode?: 'basic' | 'advanced' | 'none'
isSelf?: boolean
preloadedFingerprint?: string | null
preloadedFingerprintStatus?: string | null
}
type FingerprintStatus = 'loading' | 'no-key' | 'trusted' | 'refused' | 'unknown' | 'error'
export function EncryptionIdentityDialog({
isOpen,
onOpenChange,
participantName,
participantEmail,
suiteUserId,
isAuthenticated,
encryptionMode,
isSelf,
preloadedFingerprint,
preloadedFingerprintStatus,
}: EncryptionIdentityDialogProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.fingerprint' })
const { client: vaultClient } = useVaultClient()
const { isLoggedIn } = useUser()
const [status, setStatus] = useState<FingerprintStatus>(
(preloadedFingerprintStatus as FingerprintStatus) || 'loading'
)
const [fingerprint, setFingerprint] = useState<string | null>(preloadedFingerprint || null)
// Sync preloaded data when it becomes available (hook resolves after mount)
useEffect(() => {
if (preloadedFingerprintStatus) setStatus(preloadedFingerprintStatus as FingerprintStatus)
if (preloadedFingerprint) setFingerprint(preloadedFingerprint)
}, [preloadedFingerprint, preloadedFingerprintStatus])
const isBasicMode = encryptionMode !== 'advanced'
useEffect(() => {
if (!isOpen) return
// In basic mode, no fingerprint check — identity is from ProConnect only
if (isBasicMode) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
if (!vaultClient) {
setStatus('error')
return
}
if (!suiteUserId) {
setStatus(isAuthenticated ? 'no-key' : 'no-key')
return
}
let cancelled = false
async function checkFingerprint() {
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('timeout')), 3000)
)
const fetchResult = await Promise.race([
vaultClient!.fetchPublicKeys([suiteUserId!]),
timeout,
])
const publicKey = fetchResult.publicKeys[suiteUserId!]
if (!publicKey || cancelled) {
setStatus('no-key')
return
}
// Compute fingerprint from the public key (SHA-256, first 16 hex chars)
const hash = await crypto.subtle.digest('SHA-256', publicKey)
const fp = Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
if (cancelled) return
setFingerprint(fp)
// Check local registry without triggering TOFU auto-trust
const { fingerprints: known } = await Promise.race([
vaultClient!.getKnownFingerprints(),
timeout,
])
if (cancelled) return
const knownEntry = known[suiteUserId!]
if (!knownEntry) {
setStatus('unknown')
} else if (knownEntry.fingerprint === fp) {
setStatus(knownEntry.status)
} else {
// Fingerprint changed — needs re-verification
setStatus('unknown')
}
} catch {
if (!cancelled) setStatus('error')
}
}
checkFingerprint()
return () => { cancelled = true }
}, [isOpen, vaultClient, suiteUserId, isAuthenticated])
const handleAccept = async () => {
if (!vaultClient || !suiteUserId || !fingerprint) return
try {
await vaultClient.acceptFingerprint(suiteUserId, fingerprint)
setStatus('trusted')
} catch {
// Failed to accept
}
}
const handleRefuse = async () => {
if (!vaultClient || !suiteUserId || !fingerprint) return
try {
await vaultClient.refuseFingerprint(suiteUserId, fingerprint)
setStatus('refused')
} catch {
// Failed to refuse
}
}
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
role="dialog"
type="flex"
title={t('title')}
>
<VStack
gap="0.75rem"
alignItems="start"
className={css({ maxWidth: '22rem' })}
>
<HStack gap="0.65rem" className={css({ width: '100%' })}>
<div className={css({ flexShrink: 0, transform: 'scale(0.85)' })}>
<Avatar name={participantName} bgColor="rgb(87, 44, 216)" />
</div>
<VStack gap="0" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.9rem' })}>{participantName}</Text>
<Text variant="note" className={css({ fontSize: '0.8rem', color: 'greyscale.500' })}>
{isLoggedIn && participantEmail ? participantEmail : (!isAuthenticated ? t('anonymous') : '')}
</Text>
</VStack>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isSelf
? (isAuthenticated ? t('descriptionSelf') : t('descriptionSelfAnonymous'))
: t('description')}
</Text>
{status === 'loading' && (
<Text variant="note">{t('loading')}</Text>
)}
{status === 'no-key' && isBasicMode && isAuthenticated && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: '#eff6ff',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #bfdbfe',
})}
>
<RiShieldCheckLine size={20} color="#3b82f6" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('noKeyBasicAuthenticated')}
</Text>
</HStack>
)}
{status === 'no-key' && !(isBasicMode && isAuthenticated) && !isSelf && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: '#fffbeb',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #fde68a',
})}
>
<RiAlertLine size={20} color="#f59e0b" className={css({ flexShrink: 0 })} />
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isAuthenticated ? t('noKey') : t('noKeyAnonymous')}
</Text>
</HStack>
)}
{status === 'error' && (
<Text variant="note" className={css({ color: '#ef4444' })}>
{t('error')}
</Text>
)}
{(status === 'trusted' || status === 'refused' || status === 'unknown') && fingerprint && (
<>
<VStack
gap="0.25rem"
className={css({
backgroundColor: 'greyscale.50',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
fontFamily: 'monospace',
fontSize: '0.85rem',
letterSpacing: '0.05em',
wordBreak: 'break-all',
})}
>
<Text variant="note" className={css({ fontSize: '0.7rem', fontFamily: 'inherit' })}>
{t('fingerprintLabel')}
</Text>
{formatFingerprint(fingerprint)}
</VStack>
{status === 'trusted' && (
<VStack gap="0.25rem" alignItems="start">
<HStack gap="0.5rem" className={css({ color: '#22c55e' })}>
<RiShieldCheckFill size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('trusted')}
</Text>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{isSelf ? t('descriptionSelf') : t('trustedDescription')}
</Text>
{!isSelf && (
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
)}
</VStack>
)}
{status === 'refused' && (
<VStack gap="0.25rem" alignItems="start">
<HStack gap="0.5rem" className={css({ color: '#ef4444' })}>
<RiCloseLine size={18} />
<Text className={css({ fontSize: '0.85rem', fontWeight: 600, color: 'inherit' })}>
{t('refused')}
</Text>
</HStack>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('refusedDescription')}
</Text>
<Text
variant="note"
className={css({ fontSize: '0.75rem', color: 'greyscale.500', cursor: 'pointer', _hover: { textDecoration: 'underline' } })}
onClick={() => setStatus('unknown')}
>
{t('changeDecision')}
</Text>
</VStack>
)}
{status === 'unknown' && !isSelf && (
<VStack gap="0.5rem" className={css({ width: '100%' })}>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('unknownDescription')}
</Text>
<Text variant="note" className={css({ fontSize: '0.75rem', fontStyle: 'italic' })}>
{t('fingerprintHint')}
</Text>
<HStack gap="0.5rem">
<Button size="sm" variant="primary" onPress={handleAccept}>
<RiCheckLine size={16} />
{t('accept')}
</Button>
<Button size="sm" variant="secondaryText" onPress={handleRefuse}>
<RiCloseLine size={16} />
{t('refuse')}
</Button>
</HStack>
</VStack>
)}
</>
)}
</VStack>
</Dialog>
)
}
@@ -0,0 +1,118 @@
/**
* Overlay shown during encryption key exchange.
*
* When a participant joins an encrypted room, there's a brief period
* between connection and receiving the symmetric key where media
* cannot be decrypted. This overlay provides feedback during that time.
*
* After 20 seconds without the key, shows an error with a refresh button.
*/
import { css } from '@/styled-system/css'
import { VStack } from '@/styled-system/jsx'
import { Text, Button } from '@/primitives'
import { Spinner } from '@/primitives/Spinner'
import { RiLockFill, RiAlertFill, RiRefreshLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
const KEY_EXCHANGE_TIMEOUT = 20000
export function EncryptionSetupOverlay({
isSettingUp,
error,
}: {
isSettingUp: boolean
error: string | null
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption' })
const [timedOut, setTimedOut] = useState(false)
useEffect(() => {
if (!isSettingUp) {
setTimedOut(false)
return
}
const timer = setTimeout(() => setTimedOut(true), KEY_EXCHANGE_TIMEOUT)
return () => clearTimeout(timer)
}, [isSettingUp])
if (!isSettingUp && !error) return null
const showError = error || timedOut
return (
<div
className={css({
position: 'absolute',
inset: 0,
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
})}
>
<VStack gap="1rem" alignItems="center">
{showError ? (
<>
<RiAlertFill size={36} color="#f87171" />
<Text
className={css({
color: '#f87171',
fontSize: '1.1rem',
fontWeight: 500,
textAlign: 'center',
})}
>
{timedOut ? t('error.timeout') : t('error.title')}
</Text>
<Text
className={css({
color: 'greyscale.300',
fontSize: '0.85rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{error || t('error.timeoutHint')}
</Text>
<Button
variant="primary"
size="sm"
onPress={() => window.location.reload()}
>
<RiRefreshLine size={16} />
{t('error.refresh')}
</Button>
</>
) : (
<>
<RiLockFill size={32} color="white" />
<Text
className={css({
color: 'white',
fontSize: '1.1rem',
fontWeight: 500,
textAlign: 'center',
})}
>
{t('settingUp.title')}
</Text>
<Text
className={css({
color: 'greyscale.300',
fontSize: '0.85rem',
textAlign: 'center',
maxWidth: '20rem',
})}
>
{t('settingUp.description')}
</Text>
<Spinner />
</>
)}
</VStack>
</div>
)
}
@@ -0,0 +1,143 @@
/**
* Modal explaining encryption trust levels.
* Shown when admin clicks the trust badge in the waiting room.
*/
import { css } from '@/styled-system/css'
import { VStack, HStack } from '@/styled-system/jsx'
import { Dialog, Text } from '@/primitives'
import { RiShieldCheckFill, RiShieldCheckLine, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
interface EncryptionTrustModalProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
participantName: string
isAuthenticated: boolean
}
export function EncryptionTrustModal({
isOpen,
onOpenChange,
participantName,
isAuthenticated,
}: EncryptionTrustModalProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'encryption.trustModal' })
return (
<Dialog
isOpen={isOpen}
onOpenChange={onOpenChange}
role="dialog"
type="flex"
title={t('title')}
>
<VStack
gap="1rem"
alignItems="start"
className={css({ maxWidth: '22rem' })}
>
<Text variant="sm">{t('intro', { name: participantName })}</Text>
{isAuthenticated ? (
<HStack
gap="0.75rem"
className={css({
backgroundColor: '#eff6ff',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #bfdbfe',
})}
>
<RiShieldCheckLine
size={24}
color="#3b82f6"
className={css({ flexShrink: 0 })}
/>
<VStack gap="0.25rem" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
{t('authenticated.title')}
</Text>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('authenticated.description')}
</Text>
</VStack>
</HStack>
) : (
<HStack
gap="0.75rem"
className={css({
backgroundColor: '#fffbeb',
padding: '0.75rem',
borderRadius: '0.5rem',
width: '100%',
border: '1px solid #fde68a',
})}
>
<RiAlertLine
size={24}
color="#f59e0b"
className={css({ flexShrink: 0 })}
/>
<VStack gap="0.25rem" alignItems="start">
<Text className={css({ fontWeight: 600, fontSize: '0.85rem' })}>
{t('anonymous.title')}
</Text>
<Text variant="note" className={css({ fontSize: '0.8rem' })}>
{t('anonymous.description')}
</Text>
</VStack>
</HStack>
)}
<VStack
gap="0.5rem"
alignItems="start"
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.200',
paddingTop: '0.75rem',
width: '100%',
})}
>
<Text
variant="note"
className={css({ fontWeight: 600, fontSize: '0.8rem' })}
>
{t('levels.title')}
</Text>
<HStack gap="0.5rem" alignItems="start">
<RiShieldCheckFill
size={16}
color="#22c55e"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.verified')}
</Text>
</HStack>
<HStack gap="0.5rem" alignItems="start">
<RiShieldCheckLine
size={16}
color="#3b82f6"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.authenticated')}
</Text>
</HStack>
<HStack gap="0.5rem" alignItems="start">
<RiAlertLine
size={16}
color="#f59e0b"
className={css({ flexShrink: 0, marginTop: '2px' })}
/>
<Text variant="note" className={css({ fontSize: '0.75rem' })}>
{t('levels.anonymous')}
</Text>
</HStack>
</VStack>
</VStack>
</Dialog>
)
}
@@ -0,0 +1,122 @@
/**
* Hybrid key distributor: determines the best key distribution method per participant.
*
* For each participant joining an encrypted call:
* 1. Check if they have a registered public key (via VaultClient/encryption library)
* If YES: wrap symmetric key with their public key (PKI path) trust level "verified"
* 2. Check if they are authenticated via ProConnect
* If YES but no public key: use ephemeral DH trust level "authenticated"
* 3. Otherwise: use ephemeral DH trust level "anonymous"
*
* The symmetric key is always the same for everyone only the distribution channel varies.
*/
import type { TrustLevel } from './types'
import { PARTICIPANT_TRUST_ATTR } from './types'
export interface ParticipantEncryptionInfo {
identity: string
trustLevel: TrustLevel
hasPublicKey: boolean
isAuthenticated: boolean
}
/**
* Determine the trust level for a participant based on their encryption capabilities.
*/
export function determineTrustLevel(
hasPublicKey: boolean,
isAuthenticated: boolean
): TrustLevel {
if (hasPublicKey) return 'verified'
if (isAuthenticated) return 'authenticated'
return 'anonymous'
}
/**
* Derive trust level from participant's server-signed attributes.
*
* The `is_authenticated` attribute is set by the backend in the LiveKit JWT token
* and cannot be spoofed by clients. It indicates whether the participant
* authenticated via OIDC (ProConnect/Keycloak).
*
* In basic encryption mode, the "verified" level is never returned because
* PKI keys are not used encryption relies on a shared passphrase, not on
* per-user public keys. The green shield would be misleading.
*
* In advanced encryption mode, "verified" means the participant has completed
* encryption onboarding and their public key is used to encrypt the symmetric key.
*/
export function getTrustLevelFromAttributes(
attributes: Record<string, string> | undefined,
encryptionMode?: 'basic' | 'advanced' | 'none',
): TrustLevel | null {
if (!attributes) return null
const isAdvanced = encryptionMode === 'advanced'
// Check for explicit trust level (set by PKI integration)
const explicitLevel = attributes[PARTICIPANT_TRUST_ATTR]
if (explicitLevel === 'verified' && isAdvanced) {
return 'verified'
}
if (explicitLevel === 'authenticated' || explicitLevel === 'anonymous') {
return explicitLevel
}
// Derive from server-signed is_authenticated attribute
if (attributes.is_authenticated === 'true') {
return 'authenticated'
}
return 'anonymous'
}
/**
* Try to distribute the symmetric key via PKI (encryption library).
* Returns true if successful, false if the participant doesn't have a public key.
*/
export async function distributeKeyViaPKI(
vaultClient: VaultClient,
symmetricKey: Uint8Array,
participantUserId: string
): Promise<{ success: boolean; encryptedKey?: ArrayBuffer }> {
try {
const { publicKeys } = await vaultClient.fetchPublicKeys([
participantUserId,
])
const publicKey = publicKeys[participantUserId]
if (!publicKey) {
return { success: false }
}
// Use encryptWithoutKey to wrap the symmetric key for this user
const { encryptedKeys } = await vaultClient.shareKeys(
symmetricKey.buffer as ArrayBuffer,
{ [participantUserId]: publicKey }
)
const encryptedKey = encryptedKeys[participantUserId]
if (!encryptedKey) {
return { success: false }
}
return { success: true, encryptedKey }
} catch (err) {
console.warn(
'[Encryption] PKI key distribution failed for participant:',
participantUserId,
err
)
return { success: false }
}
}
/**
* Encode trust level into participant attributes for badge display.
*/
export function encodeTrustLevelAttribute(
trustLevel: TrustLevel
): Record<string, string> {
return { [PARTICIPANT_TRUST_ATTR]: trustLevel }
}
@@ -0,0 +1,117 @@
# Encryption Security Architecture
## Threat model
### What E2EE protects against
- **Server-side data access**: The LiveKit SFU and Meet backend cannot read audio/video content
- **Network interception**: Media frames are encrypted before leaving the client
- **Unauthorized participants**: Restricted access + lobby ensures only admin-approved users join
### Known limitations and mitigations
#### Compromised LiveKit server (MITM on key exchange)
**Threat**: If the LiveKit server is compromised, it could perform a Man-in-the-Middle attack on the ephemeral DH key exchange, intercepting the symmetric key.
**Current mitigation**: KEY_RESPONSE is only accepted from participants with `room_admin: "true"` in their server-signed JWT attributes. This prevents non-admin participants from injecting fake keys, but does not protect against a compromised server that can forge JWT attributes.
**Planned mitigations (3 levels):**
##### Level 1 — Signed key exchange (requires encryption onboarding)
When the admin has completed encryption onboarding via `data.encryption`:
1. Admin signs the KEY_RESPONSE with their permanent private key (stored in IndexedDB)
2. Receiving participant fetches admin's public key from `data.encryption` registry
3. Verifies the signature before accepting the symmetric key
4. If signature is invalid → **reject the key, show error, cut video**
This protects against server compromise because the server cannot forge the admin's private key signature.
**Requirement**: Admin must have completed encryption onboarding. If not, falls back to Level 2.
##### Level 2 — SAS (Short Authentication String) verification
After the ephemeral DH key exchange:
1. Both parties compute SAS = hash(DH_shared_secret) → displayed as 4 emojis or a 6-digit code
2. Each participant sees the SAS on their own screen (local rendering)
3. They read it aloud to each other during the call
4. If the SAS matches → the key exchange was not intercepted
5. If the SAS doesn't match → MITM detected → reject the key
This works because:
- A MITM results in different DH shared secrets → different SAS codes
- The SAS is rendered locally — the server cannot change what appears on screen
- Real-time audio manipulation to fake the spoken SAS is extremely difficult
**Requirement**: Participants must verbally compare the SAS. Optional but recommended.
##### Level 3 — Trust the server (current default)
Relies on the LiveKit server's integrity (JWT-signed attributes). Suitable when:
- The server infrastructure is self-hosted and trusted
- The threat model does not include server compromise
- Quick, frictionless meetings are prioritized over maximum security
#### Key propagation without admin
**Current behavior**: Any participant who has the symmetric key can relay it to new joiners.
**Risk**: If the server is compromised, it could inject a fake participant who relays a compromised key.
**Planned fix**: Only accept KEY_RESPONSE from participants whose identity can be:
- Cryptographically verified (Level 1 — signature from registered public key), or
- Manually verified (Level 2 — SAS comparison)
Non-verified key relays should show a clear warning.
## Trust levels
| Level | Badge | Identity verification | Key exchange | Server compromise protection |
|-------|-------|----------------------|-------------|------------------------------|
| Verified | 🟢 Green shield | Public key registered in `data.encryption` | Signed with permanent private key | Yes — signature cannot be forged |
| Authenticated | 🔵 Blue shield | OIDC/ProConnect login | Ephemeral DH (unsigned) | No — relies on server integrity |
| Anonymous | 🟡 Orange warning | None (self-declared name) | Ephemeral DH (unsigned) | No — relies on server integrity |
#### Basic mode: unencrypted frame window on connection
**Behavior**: LiveKit's built-in Worker passes frames through unencrypted when `!isEnabled()`.
**Mitigation**: `setE2EEEnabled(true)` is called BEFORE the room connects (in Conference.tsx),
ensuring the 'enable' message reaches the Worker before any frames flow. This eliminates the
unencrypted window in normal operation. However, edge cases (Worker message queue delays,
race conditions during reconnection) could theoretically still allow a few unencrypted frames.
**Advanced mode**: VaultE2EEManager drops frames when the key isn't ready — no pass-through.
#### Basic mode: "Decryption failed" overlay may not appear with wrong passphrase
**Behavior**: When a participant joins with a wrong passphrase, the receiver may not show the
"Decryption failed" overlay. The LiveKit Worker's error throttling (`MAX_ERRORS_PER_MINUTE = 5`)
stops emitting `EncryptionError` events after 5 failures. Additionally, when a participant
reconnects, the new `ParticipantTile` mounts fresh and may not receive errors referencing
the new participant identity.
**Impact**: The user sees a black tile but no error message explaining why.
**Advanced mode**: VaultE2EEManager emits `EncryptionError` for each failure and signals
`ParticipantEncryptionStatusChanged(true)` on first successful decrypt, ensuring the overlay
appears and clears correctly.
## Implementation status
- [x] Basic E2EE with LiveKit Worker + passphrase in URL hash
- [x] Advanced E2EE with VaultClient iframe (XChaCha20-Poly1305)
- [x] Preserved codec header bytes for RTP compatibility
- [x] Admin as key authority
- [x] Server-signed trust attributes in JWT
- [x] Trust badges (verified/unknown/refused/authenticated/anonymous)
- [x] Encryption identity dialog with fingerprint verification
- [x] Encryption settings in account menu (VaultClient onboarding)
- [x] Fingerprint accept/refuse with `fingerprint-changed` event
- [x] Disable recording/transcription in encrypted rooms (backend + frontend)
- [x] Lobby bypass disabled for encrypted rooms
- [x] Backend blocks encrypted room creation when `ENCRYPTION_ENABLED=false`
- [ ] Signed KEY_RESPONSE (Level 1)
- [ ] SAS verification (Level 2)
- [ ] Restrict key propagation to verified participants only
- [x] Mitigate unencrypted frame window (setE2EEEnabled before connection)
@@ -0,0 +1,231 @@
/**
* React context provider for the centralized encryption VaultClient SDK.
*
* The client SDK is loaded at runtime via a <script> tag from the vault domain
* (data.encryption). This provider:
* - Loads the client.js script from the vault URL
* - Creates and initializes the VaultClient instance
* - Sets auth context when the user logs in
* - Tracks key state (hasKeys, publicKey)
* - Provides the client to all downstream components
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import { useUser } from '@/features/auth'
import { useConfig } from '@/api/useConfig'
export interface VaultClientContextValue {
client: VaultClient | null
isReady: boolean
isLoading: boolean
error: string | null
hasKeys: boolean | null
publicKey: ArrayBuffer | null
refreshKeyState: () => Promise<void>
}
const VaultClientContext = createContext<VaultClientContextValue>({
client: null,
isReady: false,
isLoading: true,
error: null,
hasKeys: null,
publicKey: null,
refreshKeyState: async () => {},
})
function loadClientScript(vaultUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
if (window.EncryptionClient?.VaultClient) {
resolve()
return
}
const scriptSrc = `${vaultUrl}/client.js`
const existing = document.querySelector(`script[src="${scriptSrc}"]`)
if (existing) {
existing.addEventListener('load', () => resolve())
existing.addEventListener('error', () =>
reject(new Error('Failed to load encryption client SDK'))
)
return
}
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => resolve()
script.onerror = () =>
reject(new Error('Failed to load encryption client SDK'))
document.head.appendChild(script)
})
}
export function VaultClientProvider({
children,
}: {
children: React.ReactNode
}) {
const { data: config } = useConfig()
const { i18n } = useTranslation()
const { user } = useUser()
const clientRef = useRef<VaultClient | null>(null)
const [clientInitialized, setClientInitialized] = useState(false)
const [isReady, setIsReady] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [hasKeys, setHasKeys] = useState<boolean | null>(null)
const [publicKey, setPublicKey] = useState<ArrayBuffer | null>(null)
const initRef = useRef(false)
const vaultUrl = config?.encryption?.vault_url
const interfaceUrl = config?.encryption?.interface_url
// Load script + initialize VaultClient once
useEffect(() => {
if (initRef.current || !vaultUrl || !interfaceUrl) return
initRef.current = true
let destroyed = false
async function init() {
try {
await loadClientScript(vaultUrl!)
if (destroyed) return
const client = new window.EncryptionClient.VaultClient({
vaultUrl: vaultUrl!,
interfaceUrl: interfaceUrl!,
lang: i18n.language,
})
clientRef.current = client
client.on('onboarding:complete', () => {
setHasKeys(true)
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
})
client.on('keys-changed', () => {
client
.hasKeys()
.then(({ hasKeys: exists }) => {
setHasKeys(exists)
if (exists) {
client
.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
}
})
.catch(() => {})
})
client.on('keys-destroyed', () => {
setHasKeys(false)
setPublicKey(null)
})
await client.init()
if (destroyed) {
client.destroy()
} else {
setClientInitialized(true)
}
} catch (err) {
if (!destroyed) {
setError((err as Error).message)
setIsLoading(false)
}
}
}
void init()
return () => {
destroyed = true
if (clientRef.current) {
clientRef.current.destroy()
clientRef.current = null
}
}
}, [vaultUrl, interfaceUrl, i18n.language])
// Set auth context when user is available
// Note: Meet may have anonymous users — VaultClient only works for authenticated users
// with a suite_user_id. For anonymous users, isReady stays false.
useEffect(() => {
const client = clientRef.current
if (!client || !clientInitialized) {
return
}
const suiteUserId = (user as Record<string, unknown>)?.sub as string | undefined
if (suiteUserId) {
client.setAuthContext({ suiteUserId })
setIsReady(true)
// Check key state now that auth context is set
client.hasKeys()
.then(({ hasKeys: exists }) => {
setHasKeys(exists)
if (exists) {
client.getPublicKey()
.then(({ publicKey: pk }) => setPublicKey(pk))
.catch(() => {})
}
})
.catch(() => {})
}
setIsLoading(false)
}, [clientInitialized, (user as Record<string, unknown>)?.sub])
const refreshKeyState = useCallback(async () => {
const client = clientRef.current
if (!client) return
try {
const { hasKeys: exists } = await client.hasKeys()
setHasKeys(exists)
if (exists) {
const { publicKey: pk } = await client.getPublicKey()
setPublicKey(pk)
} else {
setPublicKey(null)
}
} catch {
// Vault not available
}
}, [])
return (
<VaultClientContext.Provider
value={{
client: clientInitialized ? clientRef.current : null,
isReady,
isLoading,
error,
hasKeys,
publicKey,
refreshKeyState,
}}
>
{children}
</VaultClientContext.Provider>
)
}
export const useVaultClient = (): VaultClientContextValue =>
useContext(VaultClientContext)
@@ -0,0 +1,396 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
VaultE2EEManager,
getUnencryptedBytes,
UNENCRYPTED_BYTES,
} from './VaultE2EEManager'
// ── getUnencryptedBytes ───────────────────────────────────────────────
describe('getUnencryptedBytes', () => {
it('returns 10 for VP8 keyframes', () => {
const frame = { type: 'key', data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
UNENCRYPTED_BYTES.key
)
})
it('returns 3 for VP8 delta frames', () => {
const frame = { type: 'delta', data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedVideoFrame)).toBe(
UNENCRYPTED_BYTES.delta
)
})
it('returns 1 for audio frames (no type property)', () => {
const frame = { data: new ArrayBuffer(100) }
expect(getUnencryptedBytes(frame as unknown as RTCEncodedAudioFrame)).toBe(
UNENCRYPTED_BYTES.audio
)
})
})
// ── Mock VaultClient ──────────────────────────────────────────────────
function createMockVaultClient() {
// Simulates vault crypto: prepends 24-byte nonce + appends 16-byte MAC
const NONCE_LEN = 24
const MAC_LEN = 16
return {
encryptWithKey: vi.fn(async (data: ArrayBuffer, _key: ArrayBuffer) => {
const input = new Uint8Array(data)
const nonce = new Uint8Array(NONCE_LEN).fill(0xaa) // deterministic for tests
const ciphertext = new Uint8Array(input.length + MAC_LEN)
ciphertext.set(input) // "encrypt" = copy (for testing)
ciphertext.set(new Uint8Array(MAC_LEN).fill(0xbb), input.length) // fake MAC
const result = new Uint8Array(NONCE_LEN + ciphertext.length)
result.set(nonce)
result.set(ciphertext, NONCE_LEN)
return { encryptedData: result.buffer }
}),
decryptWithKey: vi.fn(
async (encryptedData: ArrayBuffer, _key: ArrayBuffer) => {
const input = new Uint8Array(encryptedData)
// Strip nonce (24B) and MAC (16B)
const plaintext = input.slice(NONCE_LEN, input.length - MAC_LEN)
return { data: plaintext.buffer }
}
),
} as unknown as VaultClient
}
// ── Key management ────────────────────────────────────────────────────
describe('VaultE2EEManager key management', () => {
it('stores an independent copy of the key', () => {
const vaultClient = createMockVaultClient()
const manager = new VaultE2EEManager(vaultClient)
const original = new Uint8Array([1, 2, 3, 4])
manager.setEncryptedSymmetricKey(original.buffer)
// Mutate original — should not affect stored key
original[0] = 99
// Access internal state via encryptData (which uses freshKeyBuffer)
// If the key was a view on the original, this would reflect the mutation
expect(manager.isDataChannelEncryptionEnabled).toBe(false) // _isDataChannelEncryptionEnabled not set
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(true) // key is set
})
it('isDataChannelEncryptionEnabled is false without key', () => {
const manager = new VaultE2EEManager(createMockVaultClient())
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(false)
})
it('isDataChannelEncryptionEnabled is true with key + flag', () => {
const manager = new VaultE2EEManager(createMockVaultClient())
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
manager.isDataChannelEncryptionEnabled = true
expect(manager.isDataChannelEncryptionEnabled).toBe(true)
})
})
// ── Data channel encrypt/decrypt round-trip ───────────────────────────
describe('VaultE2EEManager data channel encryption', () => {
let manager: VaultE2EEManager
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
manager = new VaultE2EEManager(vaultClient as unknown as VaultClient)
manager.setEncryptedSymmetricKey(new ArrayBuffer(32))
})
it('encryptData calls vaultClient.encryptWithKey', async () => {
const data = new Uint8Array([10, 20, 30])
const result = await manager.encryptData(data)
expect(vaultClient.encryptWithKey).toHaveBeenCalledOnce()
expect(result.payload).toBeInstanceOf(Uint8Array)
expect(result.payload.length).toBeGreaterThan(data.length) // overhead from nonce+MAC
})
it('handleEncryptedData calls vaultClient.decryptWithKey', async () => {
const data = new Uint8Array([10, 20, 30])
const encrypted = await manager.encryptData(data)
const decrypted = await manager.handleEncryptedData(
encrypted.payload,
new Uint8Array(0),
'participant-1',
0
)
expect(vaultClient.decryptWithKey).toHaveBeenCalledOnce()
expect(new Uint8Array(decrypted.payload)).toEqual(data)
})
it('encryptData throws without key', async () => {
const noKeyManager = new VaultE2EEManager(
vaultClient as unknown as VaultClient
)
await expect(noKeyManager.encryptData(new Uint8Array([1]))).rejects.toThrow(
'No encrypted symmetric key set'
)
})
it('handleEncryptedData throws without key', async () => {
const noKeyManager = new VaultE2EEManager(
vaultClient as unknown as VaultClient
)
await expect(
noKeyManager.handleEncryptedData(
new Uint8Array([1]),
new Uint8Array(0),
'p',
0
)
).rejects.toThrow('No encrypted symmetric key set')
})
})
// ── Frame format (header preservation) ────────────────────────────────
describe('Frame format — header preservation', () => {
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
})
it('encrypt preserves VP8 keyframe header (10 bytes)', async () => {
// Simulate what the sender transform does
const frameData = new Uint8Array(100)
// Fill with recognizable pattern: header = 0x01-0x0A, payload = 0xFF
for (let i = 0; i < 10; i++) frameData[i] = i + 1
frameData.fill(0xff, 10)
const unencryptedBytes = UNENCRYPTED_BYTES.key // 10
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// Reconstruct frame: [header][encrypted payload]
const newFrame = new Uint8Array(header.length + encrypted.length)
newFrame.set(header)
newFrame.set(encrypted, header.length)
// Verify header is preserved unencrypted
expect(newFrame.slice(0, 10)).toEqual(header)
// Verify the rest is different (encrypted)
expect(newFrame.length).toBeGreaterThan(frameData.length) // overhead
})
it('encrypt + decrypt round-trip preserves original frame', async () => {
const frameData = new Uint8Array(50)
for (let i = 0; i < 50; i++) frameData[i] = i
const unencryptedBytes = UNENCRYPTED_BYTES.delta // 3
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
// Encrypt
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
encryptedFrame.set(header)
encryptedFrame.set(encrypted, header.length)
// Decrypt (receiver side)
const rxHeader = encryptedFrame.slice(0, unencryptedBytes)
const rxEncrypted = encryptedFrame.slice(unencryptedBytes)
const { data } = await vaultClient.decryptWithKey(
rxEncrypted.slice().buffer,
new ArrayBuffer(32)
)
const plaintext = new Uint8Array(data)
const decryptedFrame = new Uint8Array(rxHeader.length + plaintext.length)
decryptedFrame.set(rxHeader)
decryptedFrame.set(plaintext, rxHeader.length)
// Original frame should be recovered exactly
expect(decryptedFrame).toEqual(frameData)
})
it('audio frames preserve 1 byte header', async () => {
const frameData = new Uint8Array(20)
frameData[0] = 0xfc // Opus TOC byte
frameData.fill(0xab, 1)
const unencryptedBytes = UNENCRYPTED_BYTES.audio // 1
const header = frameData.slice(0, unencryptedBytes)
const payload = frameData.slice(unencryptedBytes)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const encryptedFrame = new Uint8Array(header.length + encrypted.length)
encryptedFrame.set(header)
encryptedFrame.set(encrypted, header.length)
// First byte (Opus TOC) must be preserved
expect(encryptedFrame[0]).toBe(0xfc)
})
})
// ── Proof: data transiting through LiveKit SFU is not decipherable ────
describe('SFU sees only encrypted data', () => {
let vaultClient: ReturnType<typeof createMockVaultClient>
beforeEach(() => {
vaultClient = createMockVaultClient()
})
it('encrypted frame payload does NOT match original payload', async () => {
// Simulate a VP8 keyframe with recognizable pixel data
const frameSize = 5000 // typical small video frame
const originalFrame = new Uint8Array(frameSize)
for (let i = 0; i < frameSize; i++) originalFrame[i] = i % 256
const headerSize = UNENCRYPTED_BYTES.key // 10
const header = originalFrame.slice(0, headerSize)
const payload = originalFrame.slice(headerSize)
// Encrypt (what the sender does before sending to SFU)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// This is what the SFU sees: [header][encrypted payload]
const sfuFrame = new Uint8Array(header.length + encrypted.length)
sfuFrame.set(header)
sfuFrame.set(encrypted, header.length)
// The SFU frame is LARGER than original (nonce + MAC overhead)
expect(sfuFrame.length).toBe(originalFrame.length + 24 + 16) // +40B
// The header bytes are the same (unencrypted, needed for RTP)
expect(sfuFrame.slice(0, headerSize)).toEqual(header)
// The payload bytes are COMPLETELY DIFFERENT from the original
const sfuPayload = sfuFrame.slice(headerSize)
const originalPayload = originalFrame.slice(headerSize)
expect(sfuPayload.length).not.toBe(originalPayload.length)
expect(sfuPayload).not.toEqual(originalPayload)
})
it('encrypted payload cannot be reversed without vault decryption', async () => {
const originalPayload = new Uint8Array([72, 101, 108, 108, 111]) // "Hello"
const { encryptedData } = await vaultClient.encryptWithKey(
originalPayload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
// The encrypted data is 40 bytes larger (24B nonce + 16B MAC)
expect(encrypted.length).toBe(originalPayload.length + 24 + 16)
// No substring of the encrypted data matches the original payload
// (the nonce prepended and MAC appended obscure everything)
for (let i = 0; i <= encrypted.length - originalPayload.length; i++) {
const slice = encrypted.slice(i, i + originalPayload.length)
if (i === 24) {
// At offset 24 (after nonce), our mock "encrypts" by copying,
// so in a real vault this would NOT match. Skip this offset for
// the mock — the real test is the overhead structure.
continue
}
expect(slice).not.toEqual(originalPayload)
}
})
it('overhead is exactly 40 bytes (24B nonce + 16B MAC) per frame', async () => {
const testSizes = [10, 100, 1000, 5000, 20000]
for (const size of testSizes) {
const payload = new Uint8Array(size)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.buffer,
new ArrayBuffer(32)
)
const overhead = new Uint8Array(encryptedData).length - size
expect(overhead).toBe(40) // 24B nonce + 16B MAC = XChaCha20-Poly1305
}
})
it('only codec header bytes leak — they contain no media content', () => {
// VP8 keyframe header is 10 bytes of codec metadata (not pixels)
// VP8 delta header is 3 bytes
// Opus audio header is 1 byte (TOC byte = codec config, not audio samples)
//
// These bytes tell the RTP packetizer how to split the frame into packets.
// They do NOT contain visual or audio content.
expect(UNENCRYPTED_BYTES.key).toBe(10) // VP8 payload descriptor
expect(UNENCRYPTED_BYTES.delta).toBe(3) // VP8 payload descriptor
expect(UNENCRYPTED_BYTES.audio).toBe(1) // Opus TOC byte
// Maximum leak per frame is 10 bytes out of typically 1000-50000 byte frames
// = 0.02% to 1% of frame data, and it's codec metadata, not content
const typicalKeyframeSize = 50000
const leakRatio = UNENCRYPTED_BYTES.key / typicalKeyframeSize
expect(leakRatio).toBeLessThan(0.001) // less than 0.1%
})
it('full sender→SFU→receiver pipeline: receiver recovers original, SFU cannot', async () => {
// Original video frame (sender side)
const originalFrame = new Uint8Array(200)
for (let i = 0; i < 200; i++) originalFrame[i] = (i * 7 + 13) % 256
const headerSize = UNENCRYPTED_BYTES.delta // 3
// ── SENDER: encrypt and send ──
const header = originalFrame.slice(0, headerSize)
const payload = originalFrame.slice(headerSize)
const { encryptedData } = await vaultClient.encryptWithKey(
payload.slice().buffer,
new ArrayBuffer(32)
)
const encrypted = new Uint8Array(encryptedData)
const wireFrame = new Uint8Array(header.length + encrypted.length)
wireFrame.set(header)
wireFrame.set(encrypted, header.length)
// ── SFU: can only see wireFrame — cannot recover original ──
// The SFU would need to strip the nonce and decrypt the ciphertext,
// but it doesn't have the symmetric key (it's in the vault iframe).
expect(wireFrame).not.toEqual(originalFrame)
expect(wireFrame.length).not.toBe(originalFrame.length)
// ── RECEIVER: decrypt and recover ──
const rxHeader = wireFrame.slice(0, headerSize)
const rxEncrypted = wireFrame.slice(headerSize)
const { data } = await vaultClient.decryptWithKey(
rxEncrypted.slice().buffer,
new ArrayBuffer(32)
)
const decryptedPayload = new Uint8Array(data)
const recoveredFrame = new Uint8Array(rxHeader.length + decryptedPayload.length)
recoveredFrame.set(rxHeader)
recoveredFrame.set(decryptedPayload, rxHeader.length)
// Receiver gets the EXACT original frame
expect(recoveredFrame).toEqual(originalFrame)
})
})
@@ -0,0 +1,328 @@
/**
* Custom E2EE Manager that delegates crypto to the VaultClient iframe.
*
* Uses XChaCha20-Poly1305 (libsodium) via the vault the symmetric key
* never leaves the iframe. Preserves codec header bytes unencrypted so
* the WebRTC RTP packetizer can construct valid packets.
*
* Frame format (sender output / receiver input):
* [unencrypted codec header][vault-encrypted payload]
*
* Where vault-encrypted payload = [24B nonce][ciphertext + 16B Poly1305 MAC]
*
* Unencrypted header sizes (VP8):
* - keyframe: 10 bytes (VP8 payload descriptor)
* - delta: 3 bytes
* - audio: 1 byte (Opus TOC)
*/
import { EventEmitter } from 'events'
import { Encryption_Type } from '@livekit/protocol'
import type { Room, RemoteTrack, Track } from 'livekit-client'
import { RoomEvent, ParticipantEvent, ConnectionState } from 'livekit-client'
import type { RTCEngine } from 'livekit-client/src/room/RTCEngine'
const E2EE_FLAG = Symbol('e2ee')
enum EncryptionEvent {
ParticipantEncryptionStatusChanged = 'participantEncryptionStatusChanged',
EncryptionError = 'encryptionError',
}
function isInsertableStreamSupported(): boolean {
return (
typeof window.RTCRtpSender !== 'undefined' &&
// @ts-expect-error — createEncodedStreams not in TS types
typeof window.RTCRtpSender.prototype.createEncodedStreams !== 'undefined'
)
}
export const UNENCRYPTED_BYTES = { key: 10, delta: 3, audio: 1 }
export function getUnencryptedBytes(
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame
): number {
if (!('type' in frame)) return UNENCRYPTED_BYTES.audio
return frame.type === 'key' ? UNENCRYPTED_BYTES.key : UNENCRYPTED_BYTES.delta
}
export class VaultE2EEManager extends EventEmitter {
private vaultClient: VaultClient
private room?: Room
private encryptionEnabled = false
private _isDataChannelEncryptionEnabled = false
/**
* Encrypted symmetric key (wrapped for the user's vault public key).
* Stored as an independent copy so the original ArrayBuffer can't be detached.
*/
private encryptedKeyBytes: Uint8Array | null = null
constructor(vaultClient: VaultClient) {
super()
this.vaultClient = vaultClient
}
get isEnabled() {
return this.encryptionEnabled
}
get isDataChannelEncryptionEnabled() {
return this._isDataChannelEncryptionEnabled && !!this.encryptedKeyBytes
}
set isDataChannelEncryptionEnabled(enabled: boolean) {
this._isDataChannelEncryptionEnabled = enabled
}
/** Fresh ArrayBuffer copy of the key for each vault call (avoids postMessage detachment). */
private freshKeyBuffer(): ArrayBuffer {
return new Uint8Array(this.encryptedKeyBytes!).buffer
}
setEncryptedSymmetricKey(key: ArrayBuffer): void {
this.encryptedKeyBytes = new Uint8Array(new Uint8Array(key))
}
// ── Lifecycle (mirrors built-in E2EEManager) ────────────────────────
setup(room: Room): void {
if (!isInsertableStreamSupported()) {
throw new Error(
'End-to-end encryption is not supported in this browser. ' +
'Please use a Chromium-based browser (Chrome, Edge, Brave).'
)
}
if (room !== this.room) {
this.room = room
this.setupEventListeners(room)
}
}
setupEngine(_engine: RTCEngine): void {}
setParticipantCryptorEnabled(
enabled: boolean,
participantIdentity: string
): void {
if (
participantIdentity === this.room?.localParticipant.identity &&
this.encryptionEnabled !== enabled
) {
this.encryptionEnabled = enabled
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
enabled,
this.room!.localParticipant
)
} else if (participantIdentity !== this.room?.localParticipant.identity) {
const p = this.room?.getParticipantByIdentity(participantIdentity)
if (p)
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
enabled,
p
)
}
}
setSifTrailer(_trailer: Uint8Array): void {}
async encryptData(data: Uint8Array) {
if (!this.encryptedKeyBytes)
throw new Error('No encrypted symmetric key set')
const r = await this.vaultClient.encryptWithKey(
data.slice().buffer,
this.freshKeyBuffer()
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(r.encryptedData).slice(),
iv: new Uint8Array(0),
keyIndex: 0,
}
}
async handleEncryptedData(
payload: Uint8Array,
_iv: Uint8Array,
_participantIdentity: string,
_keyIndex: number
) {
if (!this.encryptedKeyBytes)
throw new Error('No encrypted symmetric key set')
const r = await this.vaultClient.decryptWithKey(
payload.slice().buffer,
this.freshKeyBuffer()
)
return {
uuid: crypto.randomUUID(),
payload: new Uint8Array(r.data).slice(),
}
}
// ── Event listeners ─────────────────────────────────────────────────
private setupEventListeners(room: Room): void {
room.on(RoomEvent.TrackPublished, (pub, participant) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
participant.identity
)
})
room.on(RoomEvent.ConnectionStateChanged, (state) => {
if (state === ConnectionState.Connected) {
room.remoteParticipants.forEach((p) => {
p.trackPublications.forEach((pub) => {
this.setParticipantCryptorEnabled(
pub.trackInfo!.encryption !== Encryption_Type.NONE,
p.identity
)
})
})
}
})
room.on(RoomEvent.TrackSubscribed, (track, _pub, participant) => {
this.setupReceiver(track, participant.identity)
})
room.on(RoomEvent.SignalConnected, () => {
this.setParticipantCryptorEnabled(
room.localParticipant.isE2EEEnabled,
room.localParticipant.identity
)
})
room.localParticipant.on(
ParticipantEvent.LocalSenderCreated,
(sender: RTCRtpSender, track: Track) => {
this.setupSender(sender, track.mediaStreamID)
}
)
}
// ── Sender (encrypt outgoing frames) ────────────────────────────────
private setupSender(sender: RTCRtpSender, _trackId: string): void {
if (E2EE_FLAG in sender) return
if (!this.room?.localParticipant.identity) return
// @ts-expect-error — createEncodedStreams not in TS types
const streams = sender.createEncodedStreams()
const transformStream = new TransformStream({
transform: async (
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController
) => {
try {
if (!this.encryptedKeyBytes) return // drop — never send unencrypted
if (!frame.data || frame.data.byteLength === 0)
return controller.enqueue(frame)
const unencryptedBytes = getUnencryptedBytes(frame)
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
const payload = new Uint8Array(frame.data, unencryptedBytes)
const { encryptedData } = await this.vaultClient.encryptWithKey(
payload.slice().buffer,
this.freshKeyBuffer()
)
const encrypted = new Uint8Array(encryptedData)
const newData = new Uint8Array(
header.byteLength + encrypted.byteLength
)
newData.set(header)
newData.set(encrypted, header.byteLength)
frame.data = newData.buffer
controller.enqueue(frame)
} catch {
// Drop frame on error — never send unencrypted
}
},
})
streams.readable.pipeThrough(transformStream).pipeTo(streams.writable)
// @ts-expect-error
sender[E2EE_FLAG] = true
}
// ── Receiver (decrypt incoming frames) ──────────────────────────────
private setupReceiver(track: RemoteTrack, participantIdentity: string): void {
if (!track.receiver) return
const receiver = track.receiver
if (E2EE_FLAG in receiver) return
// @ts-expect-error
let writable: WritableStream = receiver.writableStream
// @ts-expect-error
let readable: ReadableStream = receiver.readableStream
if (!writable || !readable) {
// @ts-expect-error
const streams = receiver.createEncodedStreams()
// @ts-expect-error
receiver.writableStream = streams.writable
writable = streams.writable
// @ts-expect-error
receiver.readableStream = streams.readable
readable = streams.readable
}
let successEmitted = false
const transformStream = new TransformStream({
transform: async (
frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
controller: TransformStreamDefaultController
) => {
try {
if (!this.encryptedKeyBytes) return // drop — can't decrypt without key
if (!frame.data || frame.data.byteLength === 0)
return controller.enqueue(frame)
const unencryptedBytes = getUnencryptedBytes(frame)
const header = new Uint8Array(frame.data, 0, unencryptedBytes)
const encryptedPayload = new Uint8Array(frame.data, unencryptedBytes)
const { data } = await this.vaultClient.decryptWithKey(
encryptedPayload.slice().buffer,
this.freshKeyBuffer()
)
const plaintext = new Uint8Array(data)
const newData = new Uint8Array(
header.byteLength + plaintext.byteLength
)
newData.set(header)
newData.set(plaintext, header.byteLength)
frame.data = newData.buffer
controller.enqueue(frame)
if (!successEmitted) {
successEmitted = true
const p = this.room?.getParticipantByIdentity(participantIdentity)
if (p)
this.emit(
EncryptionEvent.ParticipantEncryptionStatusChanged,
true,
p
)
}
} catch {
// Drop frame — keeps pipe alive, avoids sending corrupt data to decoder
}
},
})
readable
.pipeThrough(transformStream)
.pipeTo(writable)
.catch(() => {})
// @ts-expect-error
receiver[E2EE_FLAG] = true
}
}
+104
View File
@@ -0,0 +1,104 @@
export {}
declare global {
interface VaultClient {
init(): Promise<void>
destroy(): void
setTheme(theme: string): void
setAuthContext(context: { suiteUserId: string }): void
hasKeys(): Promise<{ hasKeys: boolean }>
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>
encryptWithoutKey(
data: ArrayBuffer,
userPublicKeys: Record<string, ArrayBuffer>,
options?: { optimizeMemory?: boolean }
): Promise<{
encryptedContent: ArrayBuffer
encryptedKeys: Record<string, ArrayBuffer>
}>
encryptWithKey(
data: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer,
encryptedKeyChain?: ArrayBuffer[],
options?: { optimizeMemory?: boolean }
): Promise<{ encryptedData: ArrayBuffer }>
decryptWithKey(
encryptedData: ArrayBuffer,
encryptedSymmetricKey: ArrayBuffer,
encryptedKeyChain?: ArrayBuffer[],
options?: { optimizeMemory?: boolean }
): Promise<{ data: ArrayBuffer }>
shareKeys(
encryptedSymmetricKey: ArrayBuffer,
userPublicKeys: Record<string, ArrayBuffer>
): Promise<{ encryptedKeys: Record<string, ArrayBuffer> }>
fetchPublicKeys(
userIds: string[]
): Promise<{ publicKeys: Record<string, ArrayBuffer> }>
checkFingerprints(
userFingerprints: Record<string, string>,
currentUserId?: string
): Promise<{
results: Array<{
userId: string
knownFingerprint: string | null
providedFingerprint: string
status: 'trusted' | 'refused' | 'unknown'
}>
}>
acceptFingerprint(userId: string, fingerprint: string): Promise<void>
refuseFingerprint(userId: string, fingerprint: string): Promise<void>
getKnownFingerprints(): Promise<{
fingerprints: Record<
string,
{ fingerprint: string; status: 'trusted' | 'refused' | 'unknown' }
>
}>
openOnboarding(container: HTMLElement): void
openBackup(container: HTMLElement): void
openRestore(container: HTMLElement): void
openDeviceTransfer(container: HTMLElement): void
openSettings(container: HTMLElement): void
closeInterface(): void
on<K extends string>(event: K, listener: (data: unknown) => void): void
off<K extends string>(event: K, listener: (data: unknown) => void): void
}
/**
* Stable error codes carried by `VaultError`. Sourced from the
* encryption SDK (re-exported on `window.EncryptionClient.VaultErrorCode`)
* meet consumers match on these via `(err as VaultError).code` rather
* than regexing message text. Keep in sync with the SDK definition.
*/
type VaultErrorCode =
| 'MISSING_KEYS'
| 'WRONG_SECRET_KEY'
| 'INVALID_BACKUP'
| 'INVALID_MNEMONIC'
| 'NOT_INITIALIZED'
| 'AUTH_REQUIRED'
| 'PRIVILEGED_ORIGIN_REQUIRED'
| 'TIMEOUT'
| 'IFRAME_REQUIRED'
| 'CIPHERTEXT_TOO_SHORT'
| 'UNKNOWN'
interface VaultError extends Error {
readonly code: VaultErrorCode
}
interface Window {
EncryptionClient: {
VaultClient: new (options: {
vaultUrl: string
interfaceUrl: string
timeout?: number
theme?: string
lang?: string
}) => VaultClient
VaultError: new (code: VaultErrorCode, message: string) => VaultError
VaultErrorCode: { readonly [K in VaultErrorCode]: K }
isVaultError: (err: unknown) => err is VaultError
}
}
}
@@ -0,0 +1,17 @@
export { VaultClientProvider, useVaultClient } from './VaultClientProvider'
export type { VaultClientContextValue } from './VaultClientProvider'
export {
determineTrustLevel,
getTrustLevelFromAttributes,
distributeKeyViaPKI,
encodeTrustLevelAttribute,
} from './HybridKeyDistributor'
export type { ParticipantEncryptionInfo } from './HybridKeyDistributor'
export { EncryptionBadge } from './EncryptionBadge'
export { EncryptedMeetingBanner } from './EncryptedMeetingBanner'
export { EncryptionTrustModal } from './EncryptionTrustModal'
export { EncryptionIdentityDialog } from './EncryptionIdentityDialog'
export { useParticipantTrustLevel } from './useParticipantTrustLevel'
export { PARTICIPANT_TRUST_ATTR } from './types'
export type { TrustLevel } from './types'
@@ -0,0 +1,49 @@
/**
* Key storage and passphrase utilities for E2EE lobby flow.
*
* Basic mode: passphrase is in the URL hash shared by sharing the link.
* Advanced mode: vault-wrapped symmetric key exchanged via lobby REST API.
*/
// ── Module-level symmetric key (basic mode) ───────────────────────────
let _symmetricKey: Uint8Array | null = null
export function setSymmetricKey(key: Uint8Array): void {
_symmetricKey = key
}
export function getSymmetricKey(): Uint8Array | null {
return _symmetricKey
}
export function clearSymmetricKey(): void {
_symmetricKey = null
}
// ── Module-level encrypted vault key (advanced mode) ──────────────────
let _encryptedVaultKey: ArrayBuffer | null = null
export function setEncryptedVaultKey(key: ArrayBuffer): void {
_encryptedVaultKey = key
}
export function getEncryptedVaultKey(): ArrayBuffer | null {
return _encryptedVaultKey
}
// ── Passphrase generation (basic mode) ────────────────────────────────
/**
* Generate a random passphrase for basic mode encryption.
* 24 random bytes encoded in base36 = 48 alphanumeric characters.
*/
export function generatePassphrase(): string {
return Array.from(crypto.getRandomValues(new Uint8Array(24)))
.map((b) => b.toString(36).padStart(2, '0'))
.join('')
}
/** Expected length of a basic mode passphrase */
export const BASIC_KEY_LENGTH = 48
@@ -0,0 +1,43 @@
/**
* Trust level for a participant's encryption key distribution.
*
* - 'verified': Key was distributed via PKI (public key registered in encryption library).
* Identity is cryptographically verified.
* - 'authenticated': Key was distributed via ephemeral DH, but participant is authenticated
* via ProConnect. Identity is server-verified, not cryptographically.
* - 'anonymous': Key was distributed via ephemeral DH, participant is not authenticated.
* Identity is self-declared.
*/
export type TrustLevel = 'verified' | 'authenticated' | 'anonymous' | 'refused' | 'unknown'
/**
* Metadata attached to participant attributes for encryption trust level.
*/
export const PARTICIPANT_TRUST_ATTR = 'encryption.trustLevel'
/**
* Data channel topic for encryption key exchange protocol.
*/
export const KEY_EXCHANGE_TOPIC = 'encryption-key-exchange'
/**
* Message types for the in-call key exchange protocol.
*/
export enum KeyExchangeMessageType {
/** New participant sends their ephemeral public key to request the symmetric key */
KEY_REQUEST = 'KEY_REQUEST',
/** Existing participant responds with the symmetric key encrypted for the requester */
KEY_RESPONSE = 'KEY_RESPONSE',
/** Requester confirms receipt of the key */
KEY_ACK = 'KEY_ACK',
}
export interface KeyExchangeMessage {
type: KeyExchangeMessageType
/** Sender's participant identity */
senderIdentity: string
/** Target participant identity (for directed messages) */
targetIdentity?: string
/** Base64-encoded payload */
payload: string
}
@@ -0,0 +1,133 @@
/**
* Hook that determines a participant's trust level and fingerprint status
* by checking the vault (encryption library) via VaultClient.
*
* In advanced mode:
* - Checks if the participant has a registered public key
* - Checks the fingerprint status (trusted/refused/unknown)
* - Returns "verified" only if they have a public key
*
* In basic mode:
* - Only uses authentication status (no vault check)
*/
import { useEffect, useState } from 'react'
import { useVaultClient } from './VaultClientProvider'
import type { TrustLevel } from './types'
/** Compute a fingerprint from a public key (same as encryption repo: SHA-256, first 16 hex chars) */
async function computeFingerprint(publicKey: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', publicKey)
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.slice(0, 16)
}
/** Format for display: "a1b2c3d4e5f67890" → "A1B2 C3D4 E5F6 7890" */
export function formatFingerprint(fp: string): string {
return fp.replace(/(.{4})/g, '$1 ').trim().toUpperCase()
}
export type FingerprintStatus = 'loading' | 'trusted' | 'refused' | 'unknown' | 'no-key' | 'error'
export function useParticipantTrustLevel(
attributes: Record<string, string> | undefined,
encryptionMode?: string,
isSelf?: boolean,
): { trustLevel: TrustLevel; fingerprintStatus: FingerprintStatus; fingerprint: string | null } {
const { client: vaultClient } = useVaultClient()
const [fingerprintStatus, setFingerprintStatus] = useState<FingerprintStatus>('loading')
const [fingerprint, setFingerprint] = useState<string | null>(null)
const isAuthenticated = attributes?.is_authenticated === 'true'
const suiteUserId = attributes?.suite_user_id
const isAdvanced = encryptionMode === 'advanced'
// Re-check when a fingerprint is accepted/refused via VaultClient
const [revision, setRevision] = useState(0)
useEffect(() => {
if (!vaultClient) return
const handler = () => setRevision((r) => r + 1)
vaultClient.on('fingerprint-changed', handler)
return () => { vaultClient.off('fingerprint-changed', handler) }
}, [vaultClient])
useEffect(() => {
if (!isAdvanced || !isAuthenticated) {
setFingerprintStatus('no-key')
return
}
if (!vaultClient || !suiteUserId) {
setFingerprintStatus(vaultClient ? 'no-key' : 'error')
return
}
let cancelled = false
async function check() {
try {
const { publicKeys } = await vaultClient!.fetchPublicKeys([suiteUserId!])
if (cancelled) return
const publicKey = publicKeys[suiteUserId!]
if (!publicKey) {
setFingerprintStatus('no-key')
return
}
// Compute the fingerprint from the public key (SHA-256, first 16 hex chars)
const fp = await computeFingerprint(publicKey)
if (cancelled) return
setFingerprint(fp)
// Own fingerprint is always trusted — we hold the private key
if (isSelf) {
setFingerprintStatus('trusted')
return
}
// Check if we have a known fingerprint in the local registry
const { fingerprints: known } = await vaultClient!.getKnownFingerprints()
if (cancelled) return
const knownEntry = known[suiteUserId!]
if (!knownEntry) {
// Never seen — unknown, needs explicit acceptance
setFingerprintStatus('unknown')
} else if (knownEntry.fingerprint === fp) {
// Same fingerprint — use stored status
setFingerprintStatus(knownEntry.status as FingerprintStatus)
} else {
// Different fingerprint — key changed, needs re-verification
setFingerprintStatus('unknown')
}
} catch {
if (!cancelled) setFingerprintStatus('error')
}
}
check()
return () => { cancelled = true }
}, [vaultClient, suiteUserId, isAuthenticated, isAdvanced, isSelf, revision])
// Derive trust level from fingerprint status
let trustLevel: TrustLevel
if (!isAuthenticated) {
trustLevel = 'anonymous'
} else if (!isAdvanced) {
// Basic mode: only authentication matters
trustLevel = 'authenticated'
} else if (fingerprintStatus === 'trusted') {
trustLevel = 'verified'
} else if (fingerprintStatus === 'refused') {
trustLevel = 'refused'
} else if (fingerprintStatus === 'no-key' || fingerprintStatus === 'error') {
// Authenticated but no vault keys — show as authenticated (blue)
trustLevel = 'authenticated'
} else {
// 'unknown' or 'loading' — has key but not yet verified
trustLevel = 'unknown'
}
return { trustLevel, fingerprintStatus, fingerprint }
}
@@ -0,0 +1,142 @@
import { Button, Dialog, type DialogProps, Text } from '@/primitives'
import { VStack, HStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css'
import { RiLockFill, RiShieldCheckFill, RiAlertLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useVaultClient } from '@/features/encryption'
export const EncryptionModeDialog = ({
onSelect,
isForLater = false,
...dialogProps
}: {
onSelect: (mode: ApiEncryptionMode) => void
isForLater?: boolean
} & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'encryptionModeDialog' })
const { hasKeys, client: vaultClient, error: vaultError, isLoading: vaultLoading } = useVaultClient()
const vaultUnavailable = !vaultClient && !vaultLoading
const canUseAdvanced = !!hasKeys && !vaultUnavailable
return (
<Dialog title={t('title')} isOpen {...dialogProps}>
<VStack gap="1rem" alignItems="stretch">
<Text variant="sm" className={css({ color: 'greyscale.700' })}>
{t('description')}
</Text>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color 150ms ease, background-color 150ms ease',
_hover: {
borderColor: 'primary.500',
backgroundColor: 'primary.50',
},
})}
onClick={() => onSelect(ApiEncryptionMode.BASIC)}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiLockFill size={20} color="#2563eb" />
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({ color: 'greyscale.900' })}
>
{t('basic.title')}
</Text>
<Text variant="sm" className={css({ color: 'greyscale.600' })}>
{t('basic.description')}
</Text>
</VStack>
</button>
<div style={{ position: 'relative' }}>
<button
className={css({
display: 'flex',
gap: '0.75rem',
padding: '1rem',
borderRadius: '0.5rem',
border: '1px solid',
borderColor: 'greyscale.200',
backgroundColor: 'white',
cursor: canUseAdvanced ? 'pointer' : 'not-allowed',
textAlign: 'left',
opacity: canUseAdvanced ? 1 : 0.5,
transition:
'border-color 150ms ease, background-color 150ms ease',
_hover: canUseAdvanced
? {
borderColor: 'green.500',
backgroundColor: 'green.50',
}
: {},
})}
onClick={() => canUseAdvanced && onSelect(ApiEncryptionMode.ADVANCED)}
disabled={!canUseAdvanced}
>
<div className={css({ flexShrink: 0, paddingTop: '0.15rem' })}>
<RiShieldCheckFill
size={20}
color={canUseAdvanced ? '#166534' : '#9ca3af'}
/>
</div>
<VStack gap="0.25rem" alignItems="flex-start">
<Text
variant="sm"
bold
className={css({
color: canUseAdvanced ? 'greyscale.900' : 'greyscale.400',
})}
>
{t('advanced.title')}
</Text>
<Text
variant="sm"
className={css({
color: canUseAdvanced ? 'greyscale.600' : 'greyscale.400',
})}
>
{t('advanced.description')}
</Text>
</VStack>
</button>
{!canUseAdvanced && (
<HStack
gap="0.4rem"
className={css({
marginTop: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: vaultUnavailable ? 'red.50' : 'orange.50',
borderRadius: '0.375rem',
})}
>
<RiAlertLine
size={14}
color={vaultUnavailable ? '#dc2626' : '#d97706'}
className={css({ flexShrink: 0 })}
/>
<Text variant="note" className={css({ color: vaultUnavailable ? 'red.800' : 'orange.800' })}>
{vaultUnavailable
? t('advanced.serviceUnavailable')
: t('advanced.onboardingRequired')}
</Text>
</HStack>
)}
</div>
</VStack>
</Dialog>
)
}
@@ -1,35 +1,147 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Field, Ul, H, P, Form, Dialog } from '@/primitives' import { Field, Ul, H, P, Form, Dialog } from '@/primitives'
import { css } from '@/styled-system/css'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import { isRoomValid } from '@/features/rooms' import { isRoomValid } from '@/features/rooms'
import { normalizeRoomId } from '@/features/rooms/utils/isRoomValid'
import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
export const JoinMeetingDialog = () => { export const JoinMeetingDialog = () => {
const { t } = useTranslation('home') const { t } = useTranslation('home')
const [step, setStep] = useState<'room' | 'passphrase'>('room')
const [roomId, setRoomId] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = (data: { roomId?: FormDataEntryValue }) => { const parseInput = (input: string): { roomId: string; hash: string } => {
const roomId = (data.roomId as string) const trimmed = input.trim()
.trim() try {
.replace(`${window.location.origin}/`, '') const url = new URL(trimmed)
const id = url.pathname.replace(/^\//, '')
return { roomId: id, hash: url.hash.slice(1) }
} catch {
// Not a URL — treat as room code, normalize (add hyphens if 10 chars)
const raw = trimmed.replace(`${window.location.origin}/`, '')
return { roomId: normalizeRoomId(raw), hash: '' }
}
}
const handleRoomSubmit = async (data: { roomId?: FormDataEntryValue }) => {
const input = data.roomId as string
const parsed = parseInput(input)
// If URL already has a hash, navigate directly with it
if (parsed.hash) {
navigateTo('room', parsed.roomId)
window.location.hash = parsed.hash
return
}
// Check if the room uses basic encryption (needs passphrase)
setIsLoading(true)
try {
const room = await fetchRoom({ roomId: parsed.roomId })
if (room.encryption_mode === ApiEncryptionMode.BASIC) {
setRoomId(parsed.roomId)
setStep('passphrase')
return
}
navigateTo('room', parsed.roomId)
} catch {
// Room doesn't exist yet or error — navigate anyway
navigateTo('room', parsed.roomId)
} finally {
setIsLoading(false)
}
}
const handlePassphraseSubmit = (data: { passphrase?: FormDataEntryValue }) => {
const passphrase = (data.passphrase as string).trim()
navigateTo('room', roomId) navigateTo('room', roomId)
window.location.hash = passphrase
} }
const validateRoomId = (value: string) => { const validateRoomId = (value: string) => {
const trimmed = value.trim() const trimmed = value.trim()
if (!trimmed) return null if (!trimmed) return null
return !isRoomValid(trimmed) ? ( const { roomId: id } = parseInput(trimmed)
return !isRoomValid(id) ? (
<> <>
<p>{t('joinInputError')}</p> <p>{t('joinInputError')}</p>
<Ul> <Ul>
<li>{window.location.origin}/uio-azer-jkl</li> <li>{window.location.origin}/uio-azer-jkl</li>
<li>uio-azer-jkl</li> <li>uio-azer-jkl</li>
<li>uioazerjkl</li>
</Ul> </Ul>
</> </>
) : null ) : null
} }
if (step === 'passphrase') {
return (
<Dialog title={t('joinMeeting')}>
<Form onSubmit={handlePassphraseSubmit} submitLabel={t('joinPassphraseSubmit')}>
<P
dangerouslySetInnerHTML={{
__html: t('joinPassphraseDescription', {
interpolation: { escapeValue: false },
}),
}}
/>
<div
className={css({
backgroundColor: 'greyscale.100',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
marginBottom: '1rem',
fontSize: '0.8rem',
fontFamily: 'monospace',
wordBreak: 'break-all',
lineHeight: '1.5',
border: '1px solid',
borderColor: 'greyscale.200',
'& strong': {
color: '#16a34a',
fontWeight: 700,
},
})}
dangerouslySetInnerHTML={{
__html: t('joinPassphraseExample', {
origin: window.location.origin,
interpolation: { escapeValue: false },
}),
}}
/>
{/* eslint-disable jsx-a11y/no-autofocus */}
<Field
type="text"
autoFocus
isRequired
name="passphrase"
label={t('joinPassphraseLabel')}
errorMessage={t('joinPassphraseError')}
/>
<P
className={css({
fontSize: '0.8rem',
color: '#b45309',
marginTop: '0.5rem',
})}
>
{t('joinPassphraseWarning')}
</P>
</Form>
</Dialog>
)
}
return ( return (
<Dialog title={t('joinMeeting')}> <Dialog title={t('joinMeeting')}>
<Form onSubmit={handleSubmit} submitLabel={t('joinInputSubmit')}> <Form onSubmit={handleRoomSubmit} submitLabel={isLoading ? '...' : t('joinInputSubmit')}>
{/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */} {/* eslint-disable jsx-a11y/no-autofocus -- Focus on input when modal opens, required for accessibility */}
<Field <Field
type="text" type="text"
@@ -13,11 +13,12 @@ import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRo
// fixme - duplication with the InviteDialog // fixme - duplication with the InviteDialog
export const LaterMeetingDialog = ({ export const LaterMeetingDialog = ({
room, room,
hash,
...dialogProps ...dialogProps
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => { }: { room: null | ApiRoom; hash?: string } & Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' }) const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
const roomUrl = room && getRouteUrl('room', room?.slug) const roomUrl = room ? `${getRouteUrl('room', room.slug)}${hash ? `#${hash}` : ''}` : null
const telephony = useTelephony() const telephony = useTelephony()
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
@@ -31,7 +32,7 @@ export const LaterMeetingDialog = ({
copyRoomToClipboard, copyRoomToClipboard,
isRoomUrlCopied, isRoomUrlCopied,
copyRoomUrlToClipboard, copyRoomUrlToClipboard,
} = useCopyRoomToClipboard(room || undefined) } = useCopyRoomToClipboard(room || undefined, hash)
return ( return (
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}> <Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
+91 -5
View File
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { DialogTrigger, MenuItem, Menu as RACMenu } from 'react-aria-components' import { DialogTrigger, MenuItem, Menu as RACMenu, Separator as RACSeparator } from 'react-aria-components'
import { Button, Menu } from '@/primitives' import { Button, Menu } from '@/primitives'
import { styled } from '@/styled-system/jsx' import { styled } from '@/styled-system/jsx'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
@@ -7,8 +7,12 @@ import { Screen } from '@/layout/Screen'
import { generateRoomId, useCreateRoom } from '@/features/rooms' import { generateRoomId, useCreateRoom } from '@/features/rooms'
import { useUser, UserAware } from '@/features/auth' import { useUser, UserAware } from '@/features/auth'
import { JoinMeetingDialog } from '../components/JoinMeetingDialog' import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
import { RiAddLine, RiLink } from '@remixicon/react' import { RiAddLine, RiLink, RiLockLine, RiShieldKeyholeLine } from '@remixicon/react'
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog' import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
import { EncryptionModeDialog } from '@/features/home/components/EncryptionModeDialog'
import { ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
import { useVaultClient } from '@/features/encryption'
import { IntroSlider } from '@/features/home/components/IntroSlider' import { IntroSlider } from '@/features/home/components/IntroSlider'
import { MoreLink } from '@/features/home/components/MoreLink' import { MoreLink } from '@/features/home/components/MoreLink'
import { ReactNode, useEffect, useState } from 'react' import { ReactNode, useEffect, useState } from 'react'
@@ -155,7 +159,9 @@ export const Home = () => {
} = usePersistentUserChoices() } = usePersistentUserChoices()
const { mutateAsync: createRoom } = useCreateRoom() const { mutateAsync: createRoom } = useCreateRoom()
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null) const { client: vaultClient } = useVaultClient()
const [laterRoom, setLaterRoom] = useState<null | { room: ApiRoom; hash?: string }>(null)
const [encryptionDialogMode, setEncryptionDialogMode] = useState<null | 'instant' | 'later'>(null)
const [redirectFailed, setRedirectFailed] = useState(false) const [redirectFailed, setRedirectFailed] = useState(false)
const { data } = useConfig() const { data } = useConfig()
@@ -229,7 +235,7 @@ export const Home = () => {
onAction={() => { onAction={() => {
const slug = generateRoomId() const slug = generateRoomId()
createRoom({ slug, username }).then((data) => createRoom({ slug, username }).then((data) =>
setLaterRoom(data) setLaterRoom({ room: data })
) )
}} }}
data-attr="create-option-later" data-attr="create-option-later"
@@ -237,6 +243,37 @@ export const Home = () => {
<RiLink size={18} /> <RiLink size={18} />
{t('createMenu.laterOption')} {t('createMenu.laterOption')}
</MenuItem> </MenuItem>
{data?.encryption?.enabled && (
<>
<RACSeparator
className={css({
borderTop: '1px solid',
borderColor: 'greyscale.200',
margin: '0.25rem 0',
})}
/>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setEncryptionDialogMode('instant')}
data-attr="create-option-encrypted-instant"
>
<RiLockLine size={18} />
{t('createMenu.encryptedInstantOption')}
</MenuItem>
<MenuItem
className={
menuRecipe({ icon: true, variant: 'light' }).item
}
onAction={() => setEncryptionDialogMode('later')}
data-attr="create-option-encrypted-later"
>
<RiShieldKeyholeLine size={18} />
{t('createMenu.encryptedLaterOption')}
</MenuItem>
</>
)}
</RACMenu> </RACMenu>
</Menu> </Menu>
) : ( ) : (
@@ -265,9 +302,58 @@ export const Home = () => {
</RightColumn> </RightColumn>
</Columns> </Columns>
<LaterMeetingDialog <LaterMeetingDialog
room={laterRoom} room={laterRoom?.room ?? null}
hash={laterRoom?.hash}
onOpenChange={() => setLaterRoom(null)} onOpenChange={() => setLaterRoom(null)}
/> />
{encryptionDialogMode && (
<EncryptionModeDialog
onSelect={async (mode) => {
const dialogMode = encryptionDialogMode
setEncryptionDialogMode(null)
const slug = generateRoomId()
const hash = mode === ApiEncryptionMode.BASIC ? generatePassphrase() : undefined
let encryptedSymmetricKey = ''
if (mode === ApiEncryptionMode.ADVANCED && vaultClient) {
// encryptWithoutKey requires data to encrypt, but we only care about
// the generated symmetric key (encryptedKeys), not the encrypted content.
// The same symmetric key will be used for all streams (video/audio/chat).
const dummyData = new Uint8Array(32).buffer
const { publicKey } = await vaultClient.getPublicKey()
const { encryptedKeys } = await vaultClient.encryptWithoutKey(
dummyData,
{ self: publicKey }
)
const keyBytes = new Uint8Array(encryptedKeys['self'])
encryptedSymmetricKey = btoa(String.fromCharCode(...keyBytes))
}
createRoom({
slug,
username,
encryptionMode: mode,
encryptedSymmetricKey,
}).then((data) => {
if (dialogMode === 'instant') {
navigateTo('room', data.slug, {
state: { create: true, initialRoomData: data },
})
if (hash) {
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}#${hash}`
)
}
} else {
setLaterRoom({ room: data, hash })
}
})
}}
onOpenChange={() => setEncryptionDialogMode(null)}
/>
)}
</Screen> </Screen>
</UserAware> </UserAware>
) )
@@ -1,44 +0,0 @@
import { cva, RecipeVariantProps } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
const controlBarRegion = cva({
base: {
display: 'flex',
alignItems: 'center',
flex: '1 1 33%',
justifyContent: 'center',
gap: '0.65rem',
},
variants: {
mobile: {
true: {
justifyContent: 'space-between',
width: '330px',
},
},
},
defaultVariants: {
mobile: false,
},
})
export type ControlBarRegionProps = React.HTMLAttributes<HTMLDivElement> &
RecipeVariantProps<typeof controlBarRegion>
export function ControlBarRegion({
children,
mobile,
...props
}: ControlBarRegionProps) {
const { t } = useTranslation('rooms')
return (
<div
role="region"
aria-label={t('controls.region')}
className={controlBarRegion({ mobile })}
{...props}
>
{children}
</div>
)
}
@@ -1,86 +0,0 @@
// RoomContentArea.tsx
import React from 'react'
import { styled } from '@/styled-system/jsx'
import { cva } from '@/styled-system/css'
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { Subtitles } from '@/features/subtitle/component/Subtitles'
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
const RoomViewport = styled(
'div',
cva({
base: {
position: 'absolute',
maxHeight: '100%',
transition:
'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms, padding .5s cubic-bezier(0.4,0,0.2,1) 5ms',
},
variants: {
isSidePanelOpen: {
true: {
inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar)) 16px`,
},
false: {
inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar))`,
},
},
isReactionToolbarOpen: {
true: {
paddingBottom:
'calc(var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))',
},
false: {
paddingBottom: '0',
},
},
},
})
)
const TrackAreaContainer = styled(
'div',
cva({
base: {
position: 'relative',
display: 'flex',
width: '100%',
transition: 'height .5s cubic-bezier(0.4,0,0.2,1) 5ms',
},
variants: {
areSubtitlesOpen: {
true: {
height: 'calc(100% - 12rem)',
},
false: {
height: '100%',
},
},
},
})
)
interface RoomContentAreaProps {
children: React.ReactNode
}
export function RoomContentArea({ children }: RoomContentAreaProps) {
const { isSidePanelOpen } = useSidePanel()
const { areSubtitlesOpen } = useSubtitles()
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return (
<RoomViewport
isSidePanelOpen={isSidePanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen}
>
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
{children}
</TrackAreaContainer>
<Subtitles />
<MainNotificationToast />
</RoomViewport>
)
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect } from 'react' import { useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client' import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core' import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
@@ -10,11 +10,17 @@ import { decodeNotificationDataReceived } from './utils'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification' import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { ToastProvider, toastQueue } from './components/ToastProvider' import { ToastProvider, toastQueue } from './components/ToastProvider'
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification' import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
import {
Emoji,
Reaction,
} from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { layoutStore } from '@/stores/layout' import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel' import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { Emoji } from '@/features/reactions/types'
import { useReactions } from '@/features/reactions/hooks/useReactions'
export const MainNotificationToast = () => { export const MainNotificationToast = () => {
const room = useRoomContext() const room = useRoomContext()
@@ -22,7 +28,8 @@ export const MainNotificationToast = () => {
const { t } = useTranslation('notifications') const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const { appendReaction } = useReactions() const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
useEffect(() => { useEffect(() => {
const handleChatMessage = ( const handleChatMessage = (
@@ -55,13 +62,21 @@ export const MainNotificationToast = () => {
} }
}, [room, triggerNotificationSound, announce, t]) }, [room, triggerNotificationSound, announce, t])
const handleEmoji = useCallback( const handleEmoji = (emoji: string, participant: Participant) => {
(emoji: string, participant: Participant) => { if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return const id = instanceIdRef.current++
appendReaction(emoji as Emoji, participant) setReactions((prev) => [
}, ...prev,
[appendReaction] {
) id,
emoji,
participant,
},
])
setTimeout(() => {
setReactions((prev) => prev.filter((instance) => instance.id !== id))
}, ANIMATION_DURATION)
}
useEffect(() => { useEffect(() => {
const handleDataReceived = ( const handleDataReceived = (
@@ -134,7 +149,7 @@ export const MainNotificationToast = () => {
return () => { return () => {
room.off(RoomEvent.DataReceived, handleDataReceived) room.off(RoomEvent.DataReceived, handleDataReceived)
} }
}, [room, handleEmoji]) }, [room])
useEffect(() => { useEffect(() => {
const showJoinNotification = (participant: Participant) => { const showJoinNotification = (participant: Participant) => {
@@ -237,6 +252,7 @@ export const MainNotificationToast = () => {
<Div position="absolute" bottom={0} right={5} zIndex={1000}> <Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider /> <ToastProvider />
<WaitingParticipantNotification /> <WaitingParticipantNotification />
<ReactionPortals reactions={reactions} />
</Div> </Div>
) )
} }
@@ -12,10 +12,112 @@ import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingPartici
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useNotificationSound } from '../hooks/useSoundNotification' import { useNotificationSound } from '../hooks/useSoundNotification'
import { NotificationType } from '@/features/notifications' import { NotificationType } from '@/features/notifications'
import { EncryptionBadge, EncryptionIdentityDialog } from '@/features/encryption'
import { useParticipantTrustLevel, formatFingerprint } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
const WaitingParticipantIdentity = ({ participant }: { participant: WaitingParticipant }) => {
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const roomData = useRoomData()
const [isIdentityOpen, setIsIdentityOpen] = useState(false)
const attrs = {
is_authenticated: participant.is_authenticated ? 'true' : 'false',
suite_user_id: participant.suite_user_id || '',
}
const { trustLevel, fingerprintStatus, fingerprint } = useParticipantTrustLevel(attrs, roomData?.encryption_mode)
const badgeTooltip = tBadge(trustLevel)
return (
<>
<VStack gap="0" alignItems="start">
<Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsIdentityOpen(true)}
className={css({
padding: '0.1rem 0.25rem !important',
minWidth: 'auto !important',
height: 'auto !important',
gap: '0.15rem !important',
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'white !important',
cursor: 'pointer',
'&[data-hovered]': {
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
},
})}
>
<EncryptionBadge isEncrypted={true} trustLevel={trustLevel} />
<Text
variant="sm"
margin={false}
className={css({
maxWidth: '8rem',
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{participant.username}
</Text>
</Button>
{fingerprint && (
<Text
variant="sm"
margin={false}
className={css({
fontFamily: 'monospace',
fontSize: '0.6rem',
color: 'greyscale.100',
letterSpacing: '0.03em',
paddingLeft: '0.25rem',
})}
>
{formatFingerprint(fingerprint)}
</Text>
)}
<Text
variant="sm"
margin={false}
className={css({
fontSize: '0.7rem',
color: 'greyscale.200',
paddingLeft: '0.25rem',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '10rem',
})}
>
{participant.is_authenticated && participant.email
? participant.email
: tBadge('anonymous')}
</Text>
</VStack>
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsIdentityOpen}
participantName={participant.username}
participantEmail={participant.email}
suiteUserId={participant.suite_user_id}
isAuthenticated={participant.is_authenticated}
encryptionMode={roomData?.encryption_mode}
preloadedFingerprint={fingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
</>
)
}
export const NOTIFICATION_DISPLAY_DURATION = 10000 export const NOTIFICATION_DISPLAY_DURATION = 10000
export const WaitingParticipantNotification = () => { export const WaitingParticipantNotification = () => {
const roomData = useRoomData()
const encrypted = isEncryptedRoom(roomData)
const { triggerNotificationSound } = useNotificationSound() const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications', { const { t } = useTranslation('notifications', {
@@ -100,25 +202,29 @@ export const WaitingParticipantNotification = () => {
> >
{t('one')} {t('one')}
</Text> </Text>
<HStack gap="1rem"> <HStack gap="0.5rem">
<Avatar <Avatar
name={waitingParticipants[0].username} name={waitingParticipants[0].username}
bgColor={waitingParticipants[0].color} bgColor={waitingParticipants[0].color}
context="list" context="list"
notification notification
/> />
<Text {encrypted ? (
variant="sm" <WaitingParticipantIdentity participant={waitingParticipants[0]} />
margin={false} ) : (
className={css({ <Text
maxWidth: '10rem', variant="sm"
wordBreak: 'break-word', margin={false}
overflowWrap: 'break-word', className={css({
whiteSpace: 'normal', maxWidth: '10rem',
})} wordBreak: 'break-word',
> overflowWrap: 'break-word',
{waitingParticipants[0].username} whiteSpace: 'normal',
</Text> })}
>
{waitingParticipants[0].username}
</Text>
)}
</HStack> </HStack>
<HStack gap="0.25rem" marginLeft="auto"> <HStack gap="0.25rem" marginLeft="auto">
<Button <Button
@@ -1,51 +0,0 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { ToggleButton } from '@/primitives'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { REACTIONS_TOOLBAR_ID } from '../constants'
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
import { layoutStore } from '@/stores/layout'
const focusReactionsToolbar = () => {
document
.getElementById(REACTIONS_TOOLBAR_ID)
?.querySelector<HTMLElement>('button')
?.focus()
}
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { isOpen, toggle } = useReactionsToolbar()
const handleShortcut = useCallback(() => {
if (layoutStore.showReactionsToolbar) {
focusReactionsToolbar()
} else {
layoutStore.showReactionsToolbar = true
}
}, [])
useRegisterKeyboardShortcut({
id: 'reaction',
handler: handleShortcut,
})
return (
<ToggleButton
id="reactions-toggle"
data-attr="reactions-toggle"
square
variant="primaryDark"
aria-label={t('button')}
aria-expanded={isOpen}
tooltip={t('button')}
isSelected={isOpen}
onChange={toggle}
>
<RiEmotionLine />
</ToggleButton>
)
}
@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css'
import { Emoji } from '../../types'
import { useReactions } from '../../hooks/useReactions'
import { Button } from '@/primitives'
export const ReactionButton = ({ emoji }: { emoji: Emoji }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { sendReaction } = useReactions()
return (
<Button
onPress={() => sendReaction(emoji)}
aria-label={t(`emojis.${emoji}`)}
variant="primaryTextDark"
size="sm"
round
data-attr={`send-reaction-${emoji}`}
>
<img
src={`/assets/reactions/${emoji}.png`}
alt=""
className={css({
width: '28px',
height: '28px',
pointerEvents: 'none',
userSelect: 'none',
})}
/>
</Button>
)
}
@@ -1,155 +0,0 @@
import { FocusScope, useFocusManager } from '@react-aria/focus'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
import { ReactionButton } from './ReactionButton'
import { Emoji } from '../../types'
import { styled } from '@/styled-system/jsx'
import { layoutStore } from '@/stores/layout'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { useIsMobile } from '@/utils/useIsMobile'
import { useEffect, useRef, useState } from 'react'
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
import { useTranslation } from 'react-i18next'
const Container = styled('div', {
base: {
display: 'flex',
justifyContent: 'center',
position: 'absolute',
bottom: 'var(--sizes-room-control-bar)',
left: 0,
right: 0,
pointerEvents: 'none',
},
})
const StyledStrip = styled('div', {
base: {
display: 'flex',
gap: '0.2rem',
borderRadius: '21px',
padding: '0.15rem',
backgroundColor: 'primaryDark.100',
opacity: 0,
transform: 'translateY(3.25rem)',
transition: 'opacity, transform',
transitionDuration: '0.5s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
pointerEvents: 'none',
},
variants: {
isVisible: {
true: {
opacity: 1,
transform: 'translateY(0)',
pointerEvents: 'auto',
},
},
desktopOffset: {
true: {
// Ideally this value should be calculated dynamically in JavaScript to keep
// the reaction toolbar perfectly centered relative to the reaction toggle.
// However, for simplicity and to follow a pragmatic 80/20 approach,
// this value is currently hardcoded in CSS.
marginRight: '30px',
},
},
},
})
const Strip = ({ children }: { children: React.ReactNode }) => {
const { isOpen } = useReactionsToolbar()
const isMobile = useIsMobile()
const ref = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(false)
useEffect(() => {
if (isOpen) {
// defer one frame so the browser paints opacity:0 first
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
} else {
setIsVisible(false)
}
}, [isOpen])
return (
<StyledStrip
ref={ref}
aria-hidden={!isOpen}
isVisible={isVisible}
desktopOffset={!isMobile}
>
{children}
</StyledStrip>
)
}
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const focusManager = useFocusManager()
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
if (comingFromOutside) {
focusManager?.focusFirst()
}
}
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'ArrowRight':
focusManager?.focusNext({ wrap: true })
break
case 'ArrowLeft':
focusManager?.focusPrevious({ wrap: true })
break
case 'Escape':
e.preventDefault()
document.getElementById('reactions-toggle')?.focus()
layoutStore.showReactionsToolbar = false
break
case 'Tab':
if (!e.shiftKey) {
e.preventDefault()
getFirstControlBarFocusable('control-bar')?.focus()
}
break
}
}
return (
<div
id={REACTIONS_TOOLBAR_ID}
role="toolbar"
aria-label={t('toolbar')}
onKeyDown={onKeyDown}
onFocus={onFocus}
>
{children}
</div>
)
}
export const ReactionsToolbar = () => {
const { isOpen } = useReactionsToolbar()
const shouldMount = useDelayUnmount(isOpen, 300)
if (!shouldMount) return null
return (
<Container>
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
<FocusScope autoFocus>
<KeyboardNavigation>
<Strip>
{Object.values(Emoji).map((emoji) => (
<ReactionButton key={emoji} emoji={emoji} />
))}
</Strip>
</KeyboardNavigation>
</FocusScope>
</Container>
)
}
@@ -1,6 +0,0 @@
export const REACTIONS_TOOLBAR_ID = 'reactions-toolbar'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
export const FADE_OUT_THRESHOLD = 0.7
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
export const INITIAL_POSITION = 200
@@ -1,26 +0,0 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { getEmojiLabel } from '../utils'
import { Reaction } from '../types'
export const useAnnounceReaction = (latestReaction: Reaction | undefined) => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { announceReactions } = useSnapshot(accessibilityStore)
const [lastAnnouncedId, setLastAnnouncedId] = useState<string | null>(null)
const announce = useScreenReaderAnnounce()
useEffect(() => {
if (!announceReactions || !latestReaction) return
if (latestReaction.id === lastAnnouncedId) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participantName
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
}
@@ -1,56 +0,0 @@
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { reactionsStore } from '@/stores/reactions'
import { NotificationType } from '@/features/notifications/NotificationType'
import { useNotifyParticipants } from '@/features/notifications'
import useRateLimiter from '@/hooks/useRateLimiter'
import { Participant } from 'livekit-client'
import { Emoji } from '../types'
import { ANIMATION_DURATION } from '../constants'
export const useReactions = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const { notifyParticipants } = useNotifyParticipants()
const appendReaction = useCallback(
(emoji: Emoji, participant?: Participant) => {
const newReaction = {
id: `${emoji}-${Date.now()}-${Math.random()}`,
emoji,
participantName: participant
? participant.name || participant.identity
: t('you'),
isLocal: !participant,
}
reactionsStore.reactions.push(newReaction)
setTimeout(() => {
const index = reactionsStore.reactions.findIndex(
(r) => r.id === newReaction.id
)
if (index !== -1) reactionsStore.reactions.splice(index, 1)
}, ANIMATION_DURATION)
},
[t]
)
const sendReaction = async (emoji: Emoji) => {
appendReaction(emoji)
await notifyParticipants({
type: NotificationType.ReactionReceived,
additionalData: { data: { emoji } },
})
}
const debouncedSendReaction = useRateLimiter({
callback: sendReaction,
maxCalls: 10,
windowMs: 1000,
})
return {
sendReaction: debouncedSendReaction,
appendReaction,
}
}
@@ -1,13 +0,0 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
export const useReactionsToolbar = () => {
const layoutSnap = useSnapshot(layoutStore)
return {
isOpen: layoutSnap.showReactionsToolbar,
toggle: () => {
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
},
}
}
@@ -1,17 +0,0 @@
export enum Emoji {
THUMBS_UP = 'thumbs-up',
THUMBS_DOWN = 'thumbs-down',
CLAP = 'clapping-hands',
HEART = 'red-heart',
LAUGHING = 'face-with-tears-of-joy',
SURPRISED = 'face-with-open-mouth',
CELEBRATION = 'party-popper',
PLEASE = 'folded-hands',
}
export interface Reaction {
id: string
emoji: Emoji
participantName: string
isLocal: boolean
}
@@ -3,8 +3,6 @@ import { useQuery } from '@tanstack/react-query'
import { Center, VStack } from '@/styled-system/jsx' import { Center, VStack } from '@/styled-system/jsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTitle } from 'hoofd'
import { useMemo } from 'react'
import { mediaUrl } from '@/api/mediaUrl' import { mediaUrl } from '@/api/mediaUrl'
import { UserAware, useUser } from '@/features/auth' import { UserAware, useUser } from '@/features/auth'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
@@ -16,8 +14,6 @@ import { fetchRecording } from '../api/fetchRecording'
import { RecordingStatus } from '@/features/recording' import { RecordingStatus } from '@/features/recording'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
const BetaBadge = () => ( const BetaBadge = () => (
<span <span
className={css({ className={css({
@@ -53,21 +49,6 @@ export const RecordingDownload = () => {
enabled: !!recordingId, enabled: !!recordingId,
}) })
const isSaved =
data?.status === RecordingStatus.Saved ||
data?.status === RecordingStatus.NotificationSucceed ||
data?.status === RecordingStatus.FailedToStop
const pageTitle = useMemo(() => {
if (isError) return `${APP_TITLE} - ${t('error.title')}`
if (data && !isSaved) return `${APP_TITLE} - ${t('unsaved.title')}`
if (data?.is_expired) return `${APP_TITLE} - ${t('expired.title')}`
if (data && isSaved) return `${APP_TITLE} - ${t('success.title')}`
return APP_TITLE
}, [isError, data, isSaved, t])
useTitle(pageTitle)
if (isLoggedIn === undefined || isAuthLoading) { if (isLoggedIn === undefined || isAuthLoading) {
return <LoadingScreen /> return <LoadingScreen />
} }
@@ -10,6 +10,21 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted', RESTRICTED = 'restricted',
} }
export enum ApiEncryptionMode {
NONE = 'none',
BASIC = 'basic',
ADVANCED = 'advanced',
}
export function isEncryptedRoom(room?: { encryption_mode?: ApiEncryptionMode; encryption_enabled?: boolean } | null): boolean {
if (!room) return false
// Support both new encryption_mode and legacy encryption_enabled
if (room.encryption_mode !== undefined) {
return room.encryption_mode !== ApiEncryptionMode.NONE
}
return !!room.encryption_enabled
}
export type ApiRoom = { export type ApiRoom = {
id: string id: string
name: string name: string
@@ -17,6 +32,8 @@ export type ApiRoom = {
pin_code: string pin_code: string
is_administrable: boolean is_administrable: boolean
access_level: ApiAccessLevel access_level: ApiAccessLevel
encryption_mode: ApiEncryptionMode
encrypted_symmetric_key?: string
livekit?: ApiLiveKit livekit?: ApiLiveKit
configuration?: { configuration?: {
[key: string]: string | number | boolean | string[] [key: string]: string | number | boolean | string[]
@@ -1,24 +1,31 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi' import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError' import { ApiError } from '@/api/ApiError'
import { ApiRoom } from './ApiRoom' import { ApiRoom, ApiEncryptionMode } from './ApiRoom'
export interface CreateRoomParams { export interface CreateRoomParams {
slug: string slug: string
callbackId?: string callbackId?: string
username?: string username?: string
encryptionMode?: ApiEncryptionMode
encryptedSymmetricKey?: string
} }
const createRoom = ({ const createRoom = ({
slug, slug,
callbackId, callbackId,
username = '', username = '',
encryptionMode = ApiEncryptionMode.NONE,
encryptedSymmetricKey = '',
}: CreateRoomParams): Promise<ApiRoom> => { }: CreateRoomParams): Promise<ApiRoom> => {
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, { const queryParams = username ? `?username=${encodeURIComponent(username)}` : ''
return fetchApi(`rooms/${queryParams}`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
name: slug, name: slug,
callback_id: callbackId, callback_id: callbackId,
encryption_mode: encryptionMode,
encrypted_symmetric_key: encryptedSymmetricKey,
}), }),
}) })
} }
@@ -6,6 +6,9 @@ export interface EnterRoomParams {
roomId: string roomId: string
allowEntry: boolean allowEntry: boolean
participantId: string participantId: string
encryptedKey?: string
adminEphemeralPublicKey?: string
encryptedVaultKey?: string
} }
export interface EnterRoomResponse { export interface EnterRoomResponse {
@@ -16,12 +19,18 @@ export const enterRoom = async ({
roomId, roomId,
allowEntry, allowEntry,
participantId, participantId,
encryptedKey = '',
adminEphemeralPublicKey = '',
encryptedVaultKey = '',
}: EnterRoomParams): Promise<EnterRoomResponse> => { }: EnterRoomParams): Promise<EnterRoomResponse> => {
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, { return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
participant_id: participantId, participant_id: participantId,
allow_entry: allowEntry, allow_entry: allowEntry,
encrypted_key: encryptedKey,
admin_ephemeral_public_key: adminEphemeralPublicKey,
encrypted_vault_key: encryptedVaultKey,
}), }),
}) })
} }
@@ -8,6 +8,10 @@ export type WaitingParticipant = {
status: string status: string
username: string username: string
color: string color: string
is_authenticated: boolean
email?: string
suite_user_id?: string
ephemeral_public_key?: string
} }
export type WaitingParticipantsResponse = { export type WaitingParticipantsResponse = {
@@ -1,30 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useRenameParticipant = () => {
const data = useRoomData()
const renameParticipant = async (name: string) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const token = data?.livekit?.token
if (!token) {
throw new Error('LiveKit token is not available')
}
return fetchApi(`rooms/${data.id}/rename/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name,
}),
})
}
return { renameParticipant }
}
@@ -4,6 +4,7 @@ import { ApiLiveKit } from '@/features/rooms/api/ApiRoom'
export interface RequestEntryParams { export interface RequestEntryParams {
roomId: string roomId: string
username?: string username?: string
ephemeralPublicKey?: string
} }
export enum ApiLobbyStatus { export enum ApiLobbyStatus {
@@ -17,16 +18,21 @@ export enum ApiLobbyStatus {
export interface ApiRequestEntry { export interface ApiRequestEntry {
status: ApiLobbyStatus status: ApiLobbyStatus
livekit?: ApiLiveKit livekit?: ApiLiveKit
encrypted_key?: string
admin_ephemeral_public_key?: string
encrypted_vault_key?: string
} }
export const requestEntry = async ({ export const requestEntry = async ({
roomId, roomId,
username = '', username = '',
ephemeralPublicKey = '',
}: RequestEntryParams) => { }: RequestEntryParams) => {
return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, { return fetchApi<ApiRequestEntry>(`/rooms/${roomId}/request-entry/`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
username, username,
ephemeral_public_key: ephemeralPublicKey,
}), }),
}) })
} }
@@ -1,30 +0,0 @@
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
export const useRaiseHand = () => {
const data = useRoomData()
const raiseHand = async (raised: boolean) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const token = data?.livekit?.token
if (!token) {
throw new Error('LiveKit token is not available')
}
return fetchApi(`rooms/${data.id}/toggle-hand/`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
raised,
}),
})
}
return { raiseHand }
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
@@ -7,14 +7,23 @@ import {
} from '@livekit/components-react' } from '@livekit/components-react'
import { import {
DisconnectReason, DisconnectReason,
ExternalE2EEKeyProvider,
MediaDeviceFailure, MediaDeviceFailure,
Room, Room,
RoomOptions, RoomOptions,
VideoPresets, VideoPresets,
} from 'livekit-client' } from 'livekit-client'
import { setSymmetricKey, getSymmetricKey, getEncryptedVaultKey, generatePassphrase } from '@/features/encryption/lobbyKeyExchange'
import { isEncryptedRoom, ApiEncryptionMode } from '../api/ApiRoom'
import { VaultE2EEManager } from '@/features/encryption/VaultE2EEManager'
import { useVaultClient } from '@/features/encryption'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { CenteredContent } from '@/layout/CenteredContent'
import { RiLockLine } from '@remixicon/react'
import { Center } from '@/styled-system/jsx'
import { Text } from '@/primitives'
import { QueryAware } from '@/components/QueryAware' import { QueryAware } from '@/components/QueryAware'
import { ErrorScreen } from '@/components/ErrorScreen' import { ErrorScreen } from '@/components/ErrorScreen'
import { fetchRoom } from '../api/fetchRoom' import { fetchRoom } from '../api/fetchRoom'
@@ -31,8 +40,6 @@ import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
import { navigateTo } from '@/navigation/navigateTo' import { navigateTo } from '@/navigation/navigateTo'
import { useSnapshot } from 'valtio'
import { connectionObserverStore } from '@/stores/connectionObserver'
export const Conference = ({ export const Conference = ({
roomId, roomId,
@@ -46,8 +53,6 @@ export const Conference = ({
const posthog = usePostHog() const posthog = usePostHog()
const { data: apiConfig } = useConfig() const { data: apiConfig } = useConfig()
const connectionObserverSnap = useSnapshot(connectionObserverStore)
const { userChoices: userConfig } = usePersistentUserChoices() as { const { userChoices: userConfig } = usePersistentUserChoices() as {
userChoices: LocalUserChoices userChoices: LocalUserChoices
} }
@@ -90,12 +95,49 @@ export const Conference = ({
retry: false, retry: false,
}) })
const encryptionEnabled = isEncryptedRoom(data)
const { client: vaultClient, hasKeys: vaultHasKeys, error: vaultError, isLoading: vaultLoading } = useVaultClient()
// Determine which E2EE backend to use based solely on the room's encryption_mode.
// Advanced mode always uses VaultClient, basic mode always uses LiveKit Worker+KeyProvider.
const useVaultE2EE = data?.encryption_mode === ApiEncryptionMode.ADVANCED
// Refs for both approaches (only one is used per session)
const keyProviderRef = useRef<ExternalE2EEKeyProvider | null>(null)
const workerRef = useRef<Worker | null>(null)
const vaultManagerRef = useRef<VaultE2EEManager | null>(null)
const [encryptionSetupComplete, setEncryptionSetupComplete] = useState(!encryptionEnabled)
const getKeyProvider = () => {
if (!keyProviderRef.current && encryptionEnabled && !useVaultE2EE) {
keyProviderRef.current = new ExternalE2EEKeyProvider()
}
return keyProviderRef.current
}
const getWorker = () => {
if (!workerRef.current && encryptionEnabled && !useVaultE2EE && typeof window !== 'undefined') {
workerRef.current = new Worker(
new URL('livekit-client/e2ee-worker', import.meta.url)
)
}
return workerRef.current
}
const getVaultManager = () => {
if (!vaultManagerRef.current && useVaultE2EE && vaultClient) {
vaultManagerRef.current = new VaultE2EEManager(vaultClient)
}
return vaultManagerRef.current
}
const roomOptions = useMemo((): RoomOptions => { const roomOptions = useMemo((): RoomOptions => {
return { const baseOptions: RoomOptions = {
adaptiveStream: true, adaptiveStream: true,
dynacast: true, dynacast: true,
publishDefaults: { publishDefaults: {
videoCodec: 'vp9', videoCodec: encryptionEnabled ? undefined : 'vp9',
red: !encryptionEnabled,
}, },
videoCaptureDefaults: { videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined, deviceId: userConfig.videoDeviceId ?? undefined,
@@ -110,8 +152,25 @@ export const Conference = ({
deviceId: userConfig.audioOutputDeviceId ?? undefined, deviceId: userConfig.audioOutputDeviceId ?? undefined,
}, },
} }
if (useVaultE2EE) {
const vaultManager = getVaultManager()
if (vaultManager) {
baseOptions.encryption = { e2eeManager: vaultManager }
}
} else if (encryptionEnabled) {
const worker = getWorker()
const keyProvider = getKeyProvider()
if (keyProvider && worker) {
baseOptions.encryption = { keyProvider, worker }
}
}
return baseOptions
// do not rely on the userConfig object directly as its reference may change on every render // do not rely on the userConfig object directly as its reference may change on every render
}, [ }, [
encryptionEnabled,
useVaultE2EE,
userConfig.videoDeviceId, userConfig.videoDeviceId,
userConfig.videoPublishResolution, userConfig.videoPublishResolution,
userConfig.audioDeviceId, userConfig.audioDeviceId,
@@ -120,6 +179,132 @@ export const Conference = ({
const room = useMemo(() => new Room(roomOptions), [roomOptions]) const room = useMemo(() => new Room(roomOptions), [roomOptions])
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
*/
const serverUrl = useMemo(() => {
const livekit_url = apiConfig?.livekit.url
if (!livekit_url) return
if (apiConfig?.livekit.force_wss_protocol) {
return livekit_url.replace('https://', 'wss://')
}
return livekit_url
}, [apiConfig?.livekit])
// Encryption key setup:
// VaultE2EE: admin generates key via vaultClient.encryptWithoutKey(), joiner receives wrapped key
// Fallback: admin generates passphrase, joiner receives via lobby DH exchange
const isAdmin = mode === 'create' || data?.is_administrable === true
const adminPassphraseRef = useRef<string | null>(null)
useEffect(() => {
if (!encryptionEnabled || encryptionSetupComplete) return
if (useVaultE2EE) {
// Advanced mode: VaultE2EEManager delegates crypto to VaultClient iframe
const vaultManager = getVaultManager()
if (!vaultManager || !vaultClient) return
if (isAdmin) {
const existingKey = data?.encrypted_symmetric_key
if (existingKey) {
const binaryStr = atob(existingKey)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
vaultManager.setEncryptedSymmetricKey(bytes.buffer)
}
} else {
const vaultKey = getEncryptedVaultKey()
if (vaultKey) {
vaultManager.setEncryptedSymmetricKey(vaultKey)
}
}
// Enable E2EE BEFORE connecting — no tracks exist yet so
// republishAllTracks() is a no-op. Calling after connection
// triggers republish which times out.
room.setE2EEEnabled(true).catch((err) => {
console.error('[VaultE2EE] E2EE enable failed:', err)
})
setEncryptionSetupComplete(true)
return
}
// Basic mode: LiveKit Worker+KeyProvider with passphrase
const keyProvider = getKeyProvider()
if (!keyProvider) return
let passphrase: string | null = null
if (isAdmin) {
if (!adminPassphraseRef.current) {
const existingHash = window.location.hash.slice(1)
if (existingHash) {
adminPassphraseRef.current = existingHash
} else {
adminPassphraseRef.current = generatePassphrase()
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${window.location.search}#${adminPassphraseRef.current}`
)
}
}
passphrase = adminPassphraseRef.current
setSymmetricKey(new TextEncoder().encode(passphrase))
} else {
const hashKey = window.location.hash.slice(1)
if (hashKey) {
passphrase = hashKey
setSymmetricKey(new TextEncoder().encode(passphrase))
} else {
const preExchangedKey = getSymmetricKey()
if (preExchangedKey) {
passphrase = new TextDecoder().decode(preExchangedKey)
}
}
}
if (!passphrase) {
console.error('[Encryption] No passphrase available')
return
}
keyProvider
.setKey(passphrase)
.then(async () => {
// Enable E2EE BEFORE connecting — sets encryptionType=GCM so tracks
// are published with encryption metadata from the start.
// Also sends 'enable' to the Worker before any frames flow,
// eliminating the unencrypted frame window.
try {
await room.setE2EEEnabled(true)
} catch (err) {
console.error('[Encryption] E2EE enable failed:', err)
}
setEncryptionSetupComplete(true)
})
.catch((err) => {
console.error('[Encryption] Key setup failed:', err)
})
}, [room, encryptionEnabled, encryptionSetupComplete, isAdmin, useVaultE2EE])
// In basic encrypted rooms, the passphrase is in the URL hash.
// If the user changes the hash (e.g. corrects a typo), reload the page
// so the new passphrase is picked up by the encryption setup.
useEffect(() => {
if (!encryptionEnabled || useVaultE2EE) return
const handleHashChange = () => {
window.location.reload()
}
window.addEventListener('hashchange', handleHashChange)
return () => window.removeEventListener('hashchange', handleHashChange)
}, [encryptionEnabled, useVaultE2EE])
useEffect(() => { useEffect(() => {
/** /**
* Warm up connection to LiveKit server before joining room * Warm up connection to LiveKit server before joining room
@@ -176,20 +361,6 @@ export const Conference = ({
const isMobile = useIsMobile() const isMobile = useIsMobile()
/*
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
*/
const serverUrl = useMemo(() => {
const livekit_url = apiConfig?.livekit.url
if (!livekit_url) return
if (apiConfig?.livekit.force_wss_protocol) {
return livekit_url.replace('https://', 'wss://')
}
return livekit_url
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
if (isCreateError) { if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user. // this error screen should be replaced by a proper waiting room for anonymous user.
@@ -201,6 +372,67 @@ export const Conference = ({
) )
} }
// Block entry to advanced encrypted rooms when vault service is unavailable
if (useVaultE2EE && !vaultLoading && !vaultClient) {
return (
<Screen layout="centered">
<CenteredContent withBackButton>
<Center>
<div
className={css({
maxWidth: '400px',
backgroundColor: 'white',
borderRadius: '1rem',
padding: '2rem',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.08)',
border: '1px solid',
borderColor: 'greyscale.200',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '1rem',
textAlign: 'center',
})}
>
<div
className={css({
width: '3.5rem',
height: '3.5rem',
borderRadius: '50%',
backgroundColor: '#fef2f2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<RiLockLine size={28} color="#dc2626" />
</div>
<Text as="h2" className={css({ fontWeight: 700, fontSize: '1.15rem' })}>
{t('encryption.error.title')}
</Text>
<Text as="p" className={css({ fontSize: '0.9rem', color: 'greyscale.700' })}>
{t('encryption.error.vaultUnavailable')}
</Text>
<div
className={css({
backgroundColor: '#fffbeb',
border: '1px solid #fde68a',
borderRadius: '0.5rem',
padding: '0.75rem 1rem',
width: '100%',
})}
>
<Text as="p" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
{t('encryption.error.vaultUnavailableHint')}
</Text>
</div>
</div>
</Center>
</CenteredContent>
</Screen>
)
}
// Some clients (like DINUM) operate in bandwidth-constrained environments // Some clients (like DINUM) operate in bandwidth-constrained environments
// These settings help ensure successful connections in poor network conditions // These settings help ensure successful connections in poor network conditions
const connectOptions = { const connectOptions = {
@@ -215,7 +447,7 @@ export const Conference = ({
room={room} room={room}
serverUrl={serverUrl} serverUrl={serverUrl}
token={data?.livekit?.token} token={data?.livekit?.token}
connect={isConnectionWarmedUp} connect={isConnectionWarmedUp && encryptionSetupComplete}
audio={userConfig.audioEnabled} audio={userConfig.audioEnabled}
video={ video={
userConfig.videoEnabled && { userConfig.videoEnabled && {
@@ -232,34 +464,9 @@ export const Conference = ({
posthog.captureException(e) posthog.captureException(e)
}} }}
onDisconnected={(e) => { onDisconnected={(e) => {
const metadata = {
room_id: roomId,
pc_publisher: connectionObserverSnap.publisher && {
...connectionObserverSnap.publisher,
},
pc_subscriber: connectionObserverSnap.subscriber && {
...connectionObserverSnap.subscriber,
},
pc_publisher_changes_count:
connectionObserverSnap.publisherChangesCount,
pc_subscriber_changes_count:
connectionObserverSnap.subscriberChangesCount,
}
connectionObserverStore.publisher = null
connectionObserverStore.publisherChangesCount = 0
connectionObserverStore.subscriber = null
connectionObserverStore.subscriberChangesCount = 0
switch (e) { switch (e) {
case DisconnectReason.CLIENT_INITIATED: case DisconnectReason.CLIENT_INITIATED:
navigateTo( navigateTo('feedback')
'feedback',
{},
{
state: { ...metadata },
}
)
return return
case DisconnectReason.DUPLICATE_IDENTITY: case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED: case DisconnectReason.PARTICIPANT_REMOVED:
@@ -267,10 +474,7 @@ export const Conference = ({
'feedback', 'feedback',
{}, {},
{ {
state: { state: { reason: e },
reason: e,
...metadata,
},
} }
) )
return return
@@ -43,7 +43,11 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' }) const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
const roomData = useRoomData() const roomData = useRoomData()
const roomUrl = getRouteUrl('room', roomData?.slug) const baseRoomUrl = getRouteUrl('room', roomData?.slug)
// Include the hash (passphrase) for basic encrypted rooms so the full link is visible
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const telephony = useTelephony() const telephony = useTelephony()
@@ -32,8 +32,122 @@ import { useQuery } from '@tanstack/react-query'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry' import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
import { Spinner } from '@/primitives/Spinner' import { Spinner } from '@/primitives/Spinner'
import { ApiAccessLevel } from '../api/ApiRoom' import { ApiAccessLevel, ApiEncryptionMode, isEncryptedRoom as checkEncryptedRoom } from '../api/ApiRoom'
import { useVaultClient } from '@/features/encryption'
import { LoginButton } from '@/components/LoginButton'
const AdvancedOnboardingScreen = ({
modalOpen,
onModalOpenChange,
}: {
modalOpen: boolean
onModalOpenChange: (open: boolean) => void
}) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { client: vaultClient } = useVaultClient()
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!modalOpen || !vaultClient) return
const el = containerRef.current
if (!el) return
el.innerHTML = ''
vaultClient.openOnboarding(el)
const handleClosed = () => {
onModalOpenChange(false)
vaultClient.off('interface:closed', handleClosed)
}
vaultClient.on('interface:closed', handleClosed)
return () => {
vaultClient.off('interface:closed', handleClosed)
}
}, [modalOpen, vaultClient, onModalOpenChange])
return (
<>
<VStack alignItems="center" textAlign="center" gap="0.75rem">
<RiLockLine size={32} color="#d97706" />
<H lvl={1} margin={false} centered>
{t('advancedOnboarding.title')}
</H>
<Text as="p" variant="note">
{t('advancedOnboarding.body')}
</Text>
<Button
variant="primary"
onPress={() => onModalOpenChange(true)}
>
{t('advancedOnboarding.button')}
</Button>
</VStack>
{modalOpen && (
<div
className={css({
position: 'fixed',
inset: 0,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
})}
onClick={(e) => {
if (e.target === e.currentTarget) {
onModalOpenChange(false)
vaultClient?.closeInterface()
}
}}
>
<div
className={css({
backgroundColor: 'white',
borderRadius: '0.75rem',
width: '90%',
maxWidth: '550px',
maxHeight: '85vh',
overflow: 'auto',
position: 'relative',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
})}
>
<button
onClick={() => {
onModalOpenChange(false)
vaultClient?.closeInterface()
}}
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
zIndex: 1,
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: '1.25rem',
color: 'greyscale.500',
_hover: { color: 'greyscale.900' },
})}
aria-label="Close"
>
</button>
<div
ref={containerRef}
className={css({ minHeight: '300px' })}
/>
</div>
</div>
)}
</>
)
}
import { useLoginHint } from '@/hooks/useLoginHint' import { useLoginHint } from '@/hooks/useLoginHint'
import { useUser } from '@/features/auth'
import { RiInformationLine, RiLockLine } from '@remixicon/react'
import { openPermissionsDialog } from '@/stores/permissions' import { openPermissionsDialog } from '@/stores/permissions'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId' import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
@@ -102,6 +216,31 @@ export const Join = ({
roomId: string roomId: string
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { isLoggedIn, user } = useUser()
// Early fetch to check if the room is encrypted (needed before form submission)
const { data: roomInfo } = useQuery({
queryKey: [keys.room, roomId, 'info'],
queryFn: () => fetchRoom({ roomId }),
staleTime: 6 * 60 * 60 * 1000,
retry: false,
})
const isEncryptedRoom = checkEncryptedRoom(roomInfo)
const isBasicEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.BASIC
const isAdvancedEncrypted = roomInfo?.encryption_mode === ApiEncryptionMode.ADVANCED
// Basic mode: validate the passphrase in the URL hash
const hashKey = window.location.hash.slice(1)
const hasValidBasicKey = isBasicEncrypted ? (hashKey.length === 48 && /^[a-z0-9]+$/.test(hashKey)) : true
// Advanced mode: require auth + vault onboarding
const { hasKeys: vaultHasKeys, isReady: vaultReady } = useVaultClient()
const advancedRequiresLogin = isAdvancedEncrypted && !isLoggedIn
const advancedRequiresOnboarding = isAdvancedEncrypted && isLoggedIn && vaultReady && !vaultHasKeys
// In encrypted rooms, authenticated users must use their OIDC name
const isNameLocked = isEncryptedRoom && !!isLoggedIn
const lockedName = user?.full_name || user?.email || ''
const { const {
userChoices: { userChoices: {
@@ -325,8 +464,10 @@ export const Join = ({
roomId, roomId,
username, username,
onAccepted: handleAccepted, onAccepted: handleAccepted,
encryptionEnabled: isEncryptedRoom,
}) })
const [advancedOnboardingOpen, setAdvancedOnboardingOpen] = useState(false)
const { openLoginHint } = useLoginHint() const { openLoginHint } = useLoginHint()
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -426,6 +567,41 @@ export const Join = ({
) )
default: default:
if (advancedRequiresLogin) {
return (
<VStack alignItems="center" textAlign="center" gap="0.75rem">
<RiLockLine size={32} color="#2563eb" />
<H lvl={1} margin={false} centered>
{t('advancedAuth.title')}
</H>
<Text as="p" variant="note">
{t('advancedAuth.body')}
</Text>
<LoginButton proConnectHint={false} />
</VStack>
)
}
if (advancedRequiresOnboarding || advancedOnboardingOpen) {
return (
<AdvancedOnboardingScreen
modalOpen={advancedOnboardingOpen}
onModalOpenChange={setAdvancedOnboardingOpen}
/>
)
}
if (isBasicEncrypted && !hasValidBasicKey) {
return (
<VStack alignItems="center" textAlign="center" gap="0.75rem">
<RiLockLine size={32} color="#dc2626" />
<H lvl={1} margin={false} centered>
{t('invalidKey.title')}
</H>
<Text as="p" variant="note">
{t('invalidKey.body')}
</Text>
</VStack>
)
}
return ( return (
<Form <Form
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -438,20 +614,81 @@ export const Join = ({
<H lvl={1} margin="sm" centered> <H lvl={1} margin="sm" centered>
{t('heading')} {t('heading')}
</H> </H>
<Field {isNameLocked ? (
type="text" <div
onChange={saveUsername} className={css({
label={t('usernameLabel')} display: 'flex',
id="input-name" flexDirection: 'column',
defaultValue={username} gap: '0.25rem',
validate={(value) => !value && t('errors.usernameEmpty')} width: '100%',
wrapperProps={{ })}
noMargin: true, >
fullWidth: true, <Text
}} variant="sm"
autoComplete="name" className={css({
maxLength={50} color: 'greyscale.500',
/> fontSize: '0.8rem',
})}
>
{t('usernameLabel')}
</Text>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 0.75rem',
backgroundColor: 'greyscale.100',
borderRadius: '0.375rem',
border: '1px solid',
borderColor: 'greyscale.200',
})}
>
<RiLockLine size={14} color="#6b7280" />
<Text
variant="sm"
className={css({
fontWeight: 500,
})}
>
{lockedName}
</Text>
</div>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
})}
>
<RiInformationLine size={12} color="#9ca3af" />
<Text
variant="note"
className={css({
fontSize: '0.7rem',
color: 'greyscale.400',
})}
>
{t('encryptedNameLocked')}
</Text>
</div>
</div>
) : (
<Field
type="text"
onChange={saveUsername}
label={t('usernameLabel')}
id="input-name"
defaultValue={username}
validate={(value) => !value && t('errors.usernameEmpty')}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
autoComplete="name"
maxLength={50}
/>
)}
</VStack> </VStack>
</Form> </Form>
) )
@@ -7,7 +7,6 @@ import { usePostHog } from 'posthog-js/react'
import type { PostHog } from 'posthog-js' import type { PostHog } from 'posthog-js'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled' import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
import { CandidateInfo } from '@/stores/connectionObserver'
const Card = styled('div', { const Card = styled('div', {
base: { base: {
@@ -74,11 +73,9 @@ const labelRecipe = cva({
const OpenFeedback = ({ const OpenFeedback = ({
posthog, posthog,
onNext, onNext,
metadata,
}: { }: {
posthog: PostHog posthog: PostHog
onNext: () => void onNext: () => void
metadata?: Record<string, unknown>
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' }) const { t } = useTranslation('rooms', { keyPrefix: 'openFeedback' })
const [feedback, setFeedback] = useState('') const [feedback, setFeedback] = useState('')
@@ -90,9 +87,9 @@ const OpenFeedback = ({
const onSubmit = () => { const onSubmit = () => {
try { try {
posthog.capture('open-feedback', { posthog.capture('survey sent', {
feedback, $survey_id: '01933c5a-5a1d-0000-ada8-e39f5918c2d4',
...metadata, $survey_response: feedback,
}) })
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
@@ -143,12 +140,10 @@ const OpenFeedback = ({
const RateQuality = ({ const RateQuality = ({
posthog, posthog,
onNext, onNext,
metadata,
maxRating = 7, maxRating = 7,
}: { }: {
posthog: PostHog posthog: PostHog
onNext: () => void onNext: () => void
metadata?: Record<string, unknown>
maxRating?: number maxRating?: number
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'rating' }) const { t } = useTranslation('rooms', { keyPrefix: 'rating' })
@@ -160,9 +155,9 @@ const RateQuality = ({
const onSubmit = () => { const onSubmit = () => {
try { try {
posthog.capture('quality-rating', { posthog.capture('survey sent', {
rating: selectedRating, $survey_id: '01933c22-d005-0000-b623-20b752171e2e',
...metadata, $survey_response: `${selectedRating}`,
}) })
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
@@ -304,19 +299,7 @@ const AuthenticationMessage = ({
) )
} }
type RatingMetadata = { export const Rating = () => {
room_id?: string
pc_publisher?: CandidateInfo
pc_subscriber?: CandidateInfo
pc_publisher_changes_count?: number
pc_subscriber_changes_count?: number
}
export const Rating = ({
metadata: metadataProp,
}: {
metadata: RatingMetadata
}) => {
const isAnalyticsEnabled = useIsAnalyticsEnabled() const isAnalyticsEnabled = useIsAnalyticsEnabled()
const posthog = usePostHog() const posthog = usePostHog()
@@ -326,36 +309,14 @@ export const Rating = ({
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const sessionId = useMemo(() => crypto.randomUUID(), [])
const metadata = useMemo(
() => ({
session_id: sessionId,
...metadataProp,
}),
[sessionId, metadataProp]
)
if (!isAnalyticsEnabled) return if (!isAnalyticsEnabled) return
if (step == 0) { if (step == 0) {
return ( return <RateQuality posthog={posthog} onNext={() => setStep(step + 1)} />
<RateQuality
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
} }
if (step == 1) { if (step == 1) {
return ( return <OpenFeedback posthog={posthog} onNext={() => setStep(step + 1)} />
<OpenFeedback
posthog={posthog}
onNext={() => setStep(step + 1)}
metadata={metadata}
/>
)
} }
if (step == 2) { if (step == 2) {
@@ -6,6 +6,10 @@ import {
ApiLobbyStatus, ApiLobbyStatus,
ApiRequestEntry, ApiRequestEntry,
} from '../api/requestEntry' } from '../api/requestEntry'
import {
setSymmetricKey,
setEncryptedVaultKey,
} from '@/features/encryption/lobbyKeyExchange'
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 1000
@@ -14,14 +18,15 @@ export const useLobby = ({
roomId, roomId,
username, username,
onAccepted, onAccepted,
encryptionEnabled = false,
}: { }: {
roomId: string roomId: string
username: string username: string
onAccepted: (e: ApiRequestEntry) => void onAccepted: (e: ApiRequestEntry) => void
encryptionEnabled?: boolean
}) => { }) => {
const [status, setStatus] = useState(ApiLobbyStatus.IDLE) const [status, setStatus] = useState(ApiLobbyStatus.IDLE)
const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null) const waitingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const clearWaitingTimeout = useCallback(() => { const clearWaitingTimeout = useCallback(() => {
if (waitingTimeoutRef.current) { if (waitingTimeoutRef.current) {
clearTimeout(waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
@@ -47,6 +52,20 @@ export const useLobby = ({
if (response.status === ApiLobbyStatus.ACCEPTED) { if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout() clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED) setStatus(ApiLobbyStatus.ACCEPTED)
// Advanced mode: vault-wrapped key
if (encryptionEnabled && response.encrypted_vault_key) {
console.info('[VaultE2EE] Joiner: received encrypted_vault_key from lobby, length:', response.encrypted_vault_key.length)
const binaryStr = atob(response.encrypted_vault_key)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
setEncryptedVaultKey(bytes.buffer)
} else if (encryptionEnabled) {
console.warn('[VaultE2EE] Joiner: ACCEPTED but no encrypted_vault_key in response', response)
}
onAccepted(response) onAccepted(response)
} else if (response.status === ApiLobbyStatus.DENIED) { } else if (response.status === ApiLobbyStatus.DENIED) {
clearWaitingTimeout() clearWaitingTimeout()
@@ -60,7 +79,7 @@ export const useLobby = ({
enabled: status === ApiLobbyStatus.WAITING, enabled: status === ApiLobbyStatus.WAITING,
}) })
const startWaiting = useCallback(() => { const startWaiting = useCallback(async () => {
setStatus(ApiLobbyStatus.WAITING) setStatus(ApiLobbyStatus.WAITING)
startWaitingTimeout() startWaitingTimeout()
}, [startWaitingTimeout]) }, [startWaitingTimeout])
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { RoomEvent } from 'livekit-client' import { RoomEvent } from 'livekit-client'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData' import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom, ApiEncryptionMode } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner' import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useEnterRoom } from '../api/enterRoom' import { useEnterRoom } from '../api/enterRoom'
import { import {
@@ -10,6 +11,8 @@ import {
} from '../api/listWaitingParticipants' } from '../api/listWaitingParticipants'
import { decodeNotificationDataReceived } from '@/features/notifications/utils' import { decodeNotificationDataReceived } from '@/features/notifications/utils'
import { NotificationType } from '@/features/notifications/NotificationType' import { NotificationType } from '@/features/notifications/NotificationType'
import { useVaultClient } from '@/features/encryption'
import { toastQueue } from '@/features/notifications/components/ToastProvider'
export const POLL_INTERVAL_MS = 1000 export const POLL_INTERVAL_MS = 1000
@@ -18,9 +21,12 @@ export const useWaitingParticipants = () => {
const roomData = useRoomData() const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice const roomId = roomData?.id || '' // FIXME - bad practice
const encrypted = checkEncryptedRoom(roomData)
const isAdvancedMode = roomData?.encryption_mode === ApiEncryptionMode.ADVANCED
const room = useRoomContext() const room = useRoomContext()
const isAdminOrOwner = useIsAdminOrOwner() const isAdminOrOwner = useIsAdminOrOwner()
const { client: vaultClient } = useVaultClient()
const handleDataReceived = useCallback((payload: Uint8Array) => { const handleDataReceived = useCallback((payload: Uint8Array) => {
const notification = decodeNotificationDataReceived(payload) const notification = decodeNotificationDataReceived(payload)
@@ -57,14 +63,91 @@ export const useWaitingParticipants = () => {
const { mutateAsync: enterRoom } = useEnterRoom() const { mutateAsync: enterRoom } = useEnterRoom()
const encryptKeyForAccept = async (participant: WaitingParticipant) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (isAdvancedMode) {
// Advanced mode: re-wrap the existing symmetric key for the joiner.
// All steps are mandatory — if any fails, the participant must NOT be accepted
// (they would join without a key and see nothing).
if (!vaultClient) {
throw new Error('Encryption service is not available')
}
if (!participant.suite_user_id) {
throw new Error('Participant has no vault identity — they may not be authenticated')
}
const adminKeyBase64 = roomData?.encrypted_symmetric_key
if (!adminKeyBase64) {
throw new Error('Admin has no encrypted symmetric key for this room')
}
console.info('[VaultE2EE] Admin: wrapping key for joiner', participant.suite_user_id)
const adminKeyBinary = atob(adminKeyBase64)
const adminKeyBytes = new Uint8Array(adminKeyBinary.length)
for (let i = 0; i < adminKeyBinary.length; i++) adminKeyBytes[i] = adminKeyBinary.charCodeAt(i)
// Fetch joiner's vault public key
const { publicKeys } = await vaultClient.fetchPublicKeys([participant.suite_user_id])
const joinerPubKey = publicKeys[participant.suite_user_id]
if (!joinerPubKey) {
throw new Error(`Could not find encryption public key for participant "${participant.username}"`)
}
// Re-wrap the symmetric key for the joiner using shareKeys
const { encryptedKeys } = await vaultClient.shareKeys(
adminKeyBytes.buffer,
{ [participant.suite_user_id]: joinerPubKey }
)
const joinerKey = encryptedKeys[participant.suite_user_id]
if (!joinerKey) {
throw new Error('Key wrapping returned no result — shareKeys failed')
}
const bytes = new Uint8Array(joinerKey)
encryptedVaultKey = btoa(String.fromCharCode(...bytes))
console.info('[VaultE2EE] Admin: key wrapped successfully, length:', encryptedVaultKey.length)
}
return { encryptedKey, adminEphemeralPublicKey, encryptedVaultKey }
}
const handleParticipantEntry = async ( const handleParticipantEntry = async (
participant: WaitingParticipant, participant: WaitingParticipant,
allowEntry: boolean allowEntry: boolean
) => { ) => {
let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (allowEntry) {
try {
const keys = await encryptKeyForAccept(participant)
encryptedKey = keys.encryptedKey
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
encryptedVaultKey = keys.encryptedVaultKey
} catch (err) {
console.error('[VaultE2EE] Cannot accept participant:', err)
toastQueue.add(
{
type: 'encryptionError' as NotificationType,
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
},
{ timeout: 8000 }
)
return
}
}
await enterRoom({ await enterRoom({
roomId: roomId, roomId: roomId,
allowEntry, allowEntry,
participantId: participant.id, participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
encryptedVaultKey,
}) })
await refetchWaiting() await refetchWaiting()
} }
@@ -76,13 +159,39 @@ export const useWaitingParticipants = () => {
setListEnabled(false) setListEnabled(false)
await Promise.all( await Promise.all(
waitingParticipants.map((participant) => waitingParticipants.map(async (participant) => {
enterRoom({ let encryptedKey = ''
let adminEphemeralPublicKey = ''
let encryptedVaultKey = ''
if (allowEntry) {
try {
const keys = await encryptKeyForAccept(participant)
encryptedKey = keys.encryptedKey
adminEphemeralPublicKey = keys.adminEphemeralPublicKey
encryptedVaultKey = keys.encryptedVaultKey
} catch (err) {
console.error('[VaultE2EE] Cannot accept participant:', err)
toastQueue.add(
{
type: 'encryptionError' as NotificationType,
message: `Cannot accept ${participant.username}: ${(err as Error).message}`,
},
{ timeout: 8000 }
)
return
}
}
return enterRoom({
roomId: roomId, roomId: roomId,
allowEntry, allowEntry,
participantId: participant.id, participantId: participant.id,
encryptedKey,
adminEphemeralPublicKey,
encryptedVaultKey,
}) })
) })
) )
await refetchWaiting() await refetchWaiting()
@@ -4,12 +4,14 @@ import { Separator as RACSeparator } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { usePatchRoom } from '@/features/rooms/api/patchRoom' import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { fetchRoom } from '@/features/rooms/api/fetchRoom' import { fetchRoom } from '@/features/rooms/api/fetchRoom'
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom' import { ApiAccessLevel, isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { queryClient } from '@/api/queryClient' import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys' import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter' import { useParams } from 'wouter'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager' import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
import { RiLockFill } from '@remixicon/react'
import { HStack } from '@/styled-system/jsx'
export const Admin = () => { export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' }) const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -166,6 +168,25 @@ export const Admin = () => {
> >
{t('access.description')} {t('access.description')}
</Text> </Text>
{isEncryptedRoom(readOnlyData) && (
<HStack
gap="0.5rem"
className={css({
backgroundColor: 'primary.100',
borderRadius: '0.5rem',
padding: '0.75rem',
marginBottom: '0.75rem',
})}
>
<RiLockFill size={16} className={css({ flexShrink: 0 })} />
<Text
variant="note"
className={css({ textStyle: 'sm' })}
>
{t('access.encryptionWarning')}
</Text>
</HStack>
)}
<Field <Field
type="radioGroup" type="radioGroup"
label={t('access.type')} label={t('access.type')}
@@ -192,11 +213,13 @@ export const Admin = () => {
value: ApiAccessLevel.PUBLIC, value: ApiAccessLevel.PUBLIC,
label: t('access.levels.public.label'), label: t('access.levels.public.label'),
description: t('access.levels.public.description'), description: t('access.levels.public.description'),
isDisabled: isEncryptedRoom(readOnlyData),
}, },
{ {
value: ApiAccessLevel.TRUSTED, value: ApiAccessLevel.TRUSTED,
label: t('access.levels.trusted.label'), label: t('access.levels.trusted.label'),
description: t('access.levels.trusted.description'), description: t('access.levels.trusted.description'),
isDisabled: isEncryptedRoom(readOnlyData),
}, },
{ {
value: ApiAccessLevel.RESTRICTED, value: ApiAccessLevel.RESTRICTED,
@@ -32,7 +32,6 @@ export const AdminToggle = ({
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isAdminOpen} isSelected={isAdminOpen}
aria-expanded={isAdminOpen}
onPress={(e) => { onPress={(e) => {
toggleAdmin() toggleAdmin()
onPress?.(e) onPress?.(e)
@@ -14,7 +14,10 @@ export const Info = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'info' }) const { t } = useTranslation('rooms', { keyPrefix: 'info' })
const data = useRoomData() const data = useRoomData()
const roomUrl = getRouteUrl('room', data?.slug) const baseRoomUrl = getRouteUrl('room', data?.slug)
const roomUrl = window.location.hash
? `${baseRoomUrl}${window.location.hash}`
: baseRoomUrl
const telephony = useTelephony() const telephony = useTelephony()
@@ -50,7 +53,14 @@ export const Info = () => {
flexDirection: 'column', flexDirection: 'column',
})} })}
> >
<Text as="p" variant="xsNote" wrap="pretty"> <Text
as="p"
variant="xsNote"
className={css({
wordBreak: 'break-all',
whiteSpace: 'normal',
})}
>
{roomUrl.replace(/^https?:\/\//, '')} {roomUrl.replace(/^https?:\/\//, '')}
</Text> </Text>
{isTelephonyReadyForUse && ( {isTelephonyReadyForUse && (
@@ -1,7 +1,6 @@
import { import {
AudioTrack, AudioTrack,
ConnectionQualityIndicator, ConnectionQualityIndicator,
LockLockedIcon,
ParticipantTileProps, ParticipantTileProps,
ScreenShareIcon, ScreenShareIcon,
useEnsureTrackRef, useEnsureTrackRef,
@@ -20,10 +19,23 @@ import {
isTrackReferencePinned, isTrackReferencePinned,
TrackReferenceOrPlaceholder, TrackReferenceOrPlaceholder,
} from '@livekit/components-core' } from '@livekit/components-core'
import { Track } from 'livekit-client' import { Track, RoomEvent } from 'livekit-client'
import type { Participant } from 'livekit-client'
import { RiHand } from '@remixicon/react' import { RiHand } from '@remixicon/react'
import { useRoomContext } from '@livekit/components-react'
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand' import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
import {
EncryptionBadge,
EncryptionIdentityDialog,
} from '@/features/encryption'
import { useParticipantTrustLevel } from '@/features/encryption/useParticipantTrustLevel'
import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
import { RiLockFill } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { MutedMicIndicator } from './MutedMicIndicator' import { MutedMicIndicator } from './MutedMicIndicator'
import { ParticipantPlaceholder } from './ParticipantPlaceholder' import { ParticipantPlaceholder } from './ParticipantPlaceholder'
import { ParticipantTileFocus } from './ParticipantTileFocus' import { ParticipantTileFocus } from './ParticipantTileFocus'
@@ -79,6 +91,51 @@ export const ParticipantTile: (
trackRef: trackReference, trackRef: trackReference,
}) })
const isEncrypted = useIsEncrypted(trackReference.participant) const isEncrypted = useIsEncrypted(trackReference.participant)
const roomData = useRoomData()
const isEncryptedRoom = checkEncryptedRoom(roomData)
const isAdmin = useIsAdminOrOwner()
const [isIdentityOpen, setIsFingerprintOpen] = React.useState(false)
const participantAttrs = trackReference.participant.attributes as Record<string, string> | undefined
const { trustLevel, fingerprintStatus, fingerprint: participantFingerprint } = useParticipantTrustLevel(participantAttrs, roomData?.encryption_mode, trackReference.participant.isLocal)
const { t: tBadge } = useTranslation('rooms', { keyPrefix: 'encryption.badge' })
const badgeTooltip = tBadge(trustLevel)
// Track decryption failures via EncryptionError events from LiveKit.
// useIsEncrypted returns true when E2EE is enabled, NOT when frames decrypt successfully.
// So we listen for actual decryption errors to know when to show the overlay.
const room = useRoomContext()
const [decryptionFailed, setDecryptionFailed] = React.useState(false)
React.useEffect(() => {
if (!isEncryptedRoom || trackReference.participant.isLocal) return
const participantIdentity = trackReference.participant.identity
const handleEncryptionError = (_error: Error, participant?: Participant) => {
if (participant?.identity === participantIdentity) {
setDecryptionFailed(true)
}
}
const handleEncryptionStatusChanged = (encrypted: boolean, participant?: Participant) => {
// Clear the error when encryption status confirms frames are decrypting
if (participant?.identity === participantIdentity && encrypted) {
setDecryptionFailed(false)
}
}
room.on(RoomEvent.EncryptionError, handleEncryptionError)
room.on(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
return () => {
room.off(RoomEvent.EncryptionError, handleEncryptionError)
room.off(RoomEvent.ParticipantEncryptionStatusChanged, handleEncryptionStatusChanged)
}
}, [room, isEncryptedRoom, trackReference.participant])
const showDecryptionError =
!trackReference.participant.isLocal &&
isEncryptedRoom &&
decryptionFailed
const layoutContext = useMaybeLayoutContext() const layoutContext = useMaybeLayoutContext()
const autoManageSubscription = useFeatureContext()?.autoSubscription const autoManageSubscription = useFeatureContext()?.autoSubscription
@@ -161,6 +218,65 @@ export const ParticipantTile: (
participant={trackReference.participant} participant={trackReference.participant}
/> />
</div> </div>
{showDecryptionError && !isScreenShare && (
<div
className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: '0 !important',
pointerEvents: 'none',
})}
>
<ParticipantPlaceholder
participant={trackReference.participant}
/>
<div
style={{
position: 'absolute',
bottom: '2.5rem',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: '0.5rem',
padding: '0.6rem 1rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.3rem',
maxWidth: '85%',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
color: '#f87171',
fontSize: '0.85rem',
fontWeight: 600,
}}
>
<RiLockFill size={14} />
<span>Decryption failed</span>
</div>
<div
style={{
color: '#d1d5db',
fontSize: '0.75rem',
textAlign: 'center',
lineHeight: 1.4,
}}
>
Check that you and this person are using the correct
meeting link. If they are the only one you can&apos;t see,
the issue is likely on their side.
</div>
</div>
</div>
)}
{!disableMetadata && ( {!disableMetadata && (
<div className="lk-participant-metadata"> <div className="lk-participant-metadata">
<HStack gap={0.25}> <HStack gap={0.25}>
@@ -215,15 +331,59 @@ export const ParticipantTile: (
}} }}
/> />
)} )}
{isEncrypted && !isScreenShare && ( {isEncryptedRoom && !isScreenShare ? (
<LockLockedIcon style={{ marginRight: '0.25rem' }} /> <Button
variant="greyscale"
size="sm"
tooltip={badgeTooltip}
aria-label={badgeTooltip}
onPress={() => setIsFingerprintOpen(true)}
className={css({
display: 'inline-flex !important',
alignItems: 'center !important',
gap: '0.15rem !important',
padding: '0.1rem 0.15rem !important',
minWidth: 'auto !important',
height: 'auto !important',
position: 'relative',
zIndex: 10,
borderRadius: '0.25rem !important',
backgroundColor: 'transparent !important',
color: 'inherit !important',
'&[data-hovered]': {
backgroundColor: 'rgba(255, 255, 255, 0.15) !important',
},
})}
>
{(isEncrypted || isEncryptedRoom) && (
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</Button>
) : (
<>
{(isEncrypted || isEncryptedRoom) && !isScreenShare && (
<EncryptionBadge
isEncrypted={true}
trustLevel={trustLevel}
/>
)}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</>
)} )}
<div className="lk-participant-name-wrapper">
<ParticipantName
isScreenShare={isScreenShare}
participant={trackReference.participant}
/>
</div>
</div> </div>
</HStack> </HStack>
<ConnectionQualityIndicator className="lk-participant-metadata-item" /> <ConnectionQualityIndicator className="lk-participant-metadata-item" />
@@ -246,6 +406,20 @@ export const ParticipantTile: (
), ),
})} })}
</KeyboardShortcutHint> </KeyboardShortcutHint>
{isEncryptedRoom && (
<EncryptionIdentityDialog
isOpen={isIdentityOpen}
onOpenChange={setIsFingerprintOpen}
participantName={trackReference.participant.name || trackReference.participant.identity}
participantEmail={participantAttrs?.email}
suiteUserId={participantAttrs?.suite_user_id}
isAuthenticated={participantAttrs?.is_authenticated === 'true'}
encryptionMode={roomData?.encryption_mode}
isSelf={trackReference.participant.isLocal}
preloadedFingerprint={participantFingerprint}
preloadedFingerprintStatus={fingerprintStatus}
/>
)}
</div> </div>
) )
}) })
@@ -2,17 +2,19 @@ import { createPortal } from 'react-dom'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Text } from '@/primitives' import { Text } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
import { accessibilityStore } from '@/stores/accessibility'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { reactionsStore } from '@/stores/reactions' import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
import { useAnnounceReaction } from '../hooks/useAnnounceReaction'
import { Reaction } from '../types' export const ANIMATION_DURATION = 3000
import { export const ANIMATION_DISTANCE = 300
ANIMATION_DISTANCE, export const FADE_OUT_THRESHOLD = 0.7
ANIMATION_DURATION, export const REACTION_SPAWN_WIDTH_RATIO = 0.2
FADE_OUT_THRESHOLD, export const INITIAL_POSITION = 200
INITIAL_POSITION,
REACTION_SPAWN_WIDTH_RATIO,
} from '../constants'
interface FloatingReactionProps { interface FloatingReactionProps {
emoji: string emoji: string
@@ -77,7 +79,7 @@ export function FloatingReaction({
> >
<img <img
src={`/assets/reactions/${emoji}.png`} src={`/assets/reactions/${emoji}.png`}
alt="" alt={''}
className={css({ className={css({
height: '50px', height: '50px',
})} })}
@@ -114,7 +116,14 @@ export function FloatingReaction({
) )
} }
const ReactionPortal = ({ reaction }: { reaction: Reaction }) => { export function ReactionPortal({
emoji,
participant,
}: {
emoji: string
participant: Participant
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const speed = useMemo(() => Math.random() * 1.5 + 0.5, []) const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), []) const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return createPortal( return createPortal(
@@ -129,27 +138,51 @@ const ReactionPortal = ({ reaction }: { reaction: Reaction }) => {
})} })}
> >
<FloatingReaction <FloatingReaction
emoji={reaction.emoji} emoji={emoji}
speed={speed} speed={speed}
scale={scale} scale={scale}
name={reaction.participantName} name={participant?.isLocal ? t('you') : participant.name}
isLocal={reaction.isLocal} isLocal={participant?.isLocal}
/> />
</div>, </div>,
document.body document.body
) )
} }
export const ReactionPortals = () => { export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) => {
const { reactions } = useSnapshot(reactionsStore) const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const latestReaction = reactions.at(-1) const { announceReactions } = useSnapshot(accessibilityStore)
const [lastAnnouncedId, setLastAnnouncedId] = useState<number | null>(null)
const announce = useScreenReaderAnnounce()
useAnnounceReaction(latestReaction) const latestReaction =
reactions.length > 0 ? reactions[reactions.length - 1] : undefined
useEffect(() => {
if (!announceReactions) {
return
}
if (!latestReaction) return
const isNewReaction = latestReaction.id !== lastAnnouncedId
if (!isNewReaction) return
const emojiLabel = getEmojiLabel(latestReaction.emoji, t)
const participantName = latestReaction.participant?.isLocal
? t('you')
: latestReaction.participant?.name?.trim() ||
t('someone', { defaultValue: 'Someone' })
announce(t('announce', { name: participantName, emoji: emojiLabel }))
setLastAnnouncedId(latestReaction.id)
}, [announce, latestReaction, lastAnnouncedId, announceReactions, t])
return ( return (
<> <>
{reactions.map((instance) => ( {reactions.map((instance) => (
<ReactionPortal key={instance.id} reaction={instance} /> <ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))} ))}
</> </>
) )
@@ -14,7 +14,6 @@ import { Admin } from './Admin'
import { Tools } from './Tools' import { Tools } from './Tools'
import { Info } from './Info' import { Info } from './Info'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
type StyledSidePanelProps = { type StyledSidePanelProps = {
title: string title: string
@@ -26,7 +25,6 @@ type StyledSidePanelProps = {
isSubmenu: boolean isSubmenu: boolean
onBack: () => void onBack: () => void
backButtonLabel: string backButtonLabel: string
isReactionToolbarOpen?: boolean
} }
const StyledSidePanel = ({ const StyledSidePanel = ({
@@ -35,7 +33,6 @@ const StyledSidePanel = ({
children, children,
onClose, onClose,
isClosed, isClosed,
isReactionToolbarOpen,
closeButtonTooltip, closeButtonTooltip,
isSubmenu = false, isSubmenu = false,
onBack, onBack,
@@ -54,23 +51,17 @@ const StyledSidePanel = ({
overflow: 'hidden', overflow: 'hidden',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
margin: 'var(--sizes-room-side-panel-margin)', margin: '1.5rem 1.5rem 1.5rem 0',
marginLeft: 0,
marginBottom: 0,
padding: 0, padding: 0,
gap: 0, gap: 0,
right: 0, right: 0,
top: 0, top: 0,
width: 'var(--sizes-room-side-panel)', bottom: '80px',
width: '360px',
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms', transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
})} })}
style={{ style={{
transform: isClosed transform: isClosed ? 'translateX(calc(360px + 1.5rem))' : 'none',
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
: 'none',
bottom: isReactionToolbarOpen
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
: 'var(--sizes-room-control-bar)',
}} }}
aria-hidden={isClosed} aria-hidden={isClosed}
aria-label={ariaLabel} aria-label={ariaLabel}
@@ -160,8 +151,6 @@ export const SidePanel = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
const title = t(`heading.${activeSubPanelId || activePanelId}`) const title = t(`heading.${activeSubPanelId || activePanelId}`)
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
return ( return (
<StyledSidePanel <StyledSidePanel
title={title} title={title}
@@ -175,7 +164,6 @@ export const SidePanel = () => {
})} })}
isClosed={!isSidePanelOpen} isClosed={!isSidePanelOpen}
isSubmenu={isSubPanelOpen} isSubmenu={isSubPanelOpen}
isReactionToolbarOpen={isReactionToolbarOpen}
backButtonLabel={t('backToTools')} backButtonLabel={t('backToTools')}
onBack={() => (layoutStore.activeSubPanelId = null)} onBack={() => (layoutStore.activeSubPanelId = null)}
> >
@@ -12,12 +12,16 @@ import {
ScreenRecordingSidePanel, ScreenRecordingSidePanel,
} from '@/features/recording' } from '@/features/recording'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { useRoomData } from '../hooks/useRoomData'
import { isEncryptedRoom } from '@/features/rooms/api/ApiRoom'
import { RiLockLine } from '@remixicon/react'
export interface ToolsButtonProps { export interface ToolsButtonProps {
icon: ReactNode icon: ReactNode
title: string title: string
description: string description: string
onPress: () => void onPress: () => void
isDisabled?: boolean
} }
const ToolButton = ({ const ToolButton = ({
@@ -25,9 +29,11 @@ const ToolButton = ({
title, title,
description, description,
onPress, onPress,
isDisabled,
}: ToolsButtonProps) => { }: ToolsButtonProps) => {
return ( return (
<RACButton <RACButton
isDisabled={isDisabled}
className={css({ className={css({
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
@@ -39,10 +45,14 @@ const ToolButton = ({
width: 'full', width: 'full',
backgroundColor: 'gray.50', backgroundColor: 'gray.50',
textAlign: 'start', textAlign: 'start',
'&[data-hovered]': { '&[data-hovered]:not([data-disabled])': {
backgroundColor: 'primary.50', backgroundColor: 'primary.50',
cursor: 'pointer', cursor: 'pointer',
}, },
'&[data-disabled]': {
opacity: 0.5,
cursor: 'not-allowed',
},
})} })}
onPress={onPress} onPress={onPress}
> >
@@ -132,6 +142,9 @@ export const Tools = () => {
break break
} }
const roomData = useRoomData()
const encrypted = isEncryptedRoom(roomData)
return ( return (
<Div <Div
display="flex" display="flex"
@@ -166,12 +179,33 @@ export const Tools = () => {
</A> </A>
)} )}
</Text> </Text>
{encrypted && (
<div
className={css({
display: 'flex',
gap: '0.5rem',
alignItems: 'start',
padding: '0.6rem 0.75rem',
backgroundColor: '#fffbeb',
borderRadius: '0.5rem',
border: '1px solid #fde68a',
marginBottom: '0.5rem',
width: '100%',
})}
>
<RiLockLine size={16} color="#d97706" className={css({ flexShrink: 0, marginTop: '0.1rem' })} />
<Text variant="note" className={css({ fontSize: '0.8rem', color: '#92400e' })}>
{t('encryptedDisabled')}
</Text>
</div>
)}
{isTranscriptEnabled && ( {isTranscriptEnabled && (
<ToolButton <ToolButton
icon={<Icon type="symbols" name="speech_to_text" />} icon={<Icon type="symbols" name="speech_to_text" />}
title={t('tools.transcript.title')} title={t('tools.transcript.title')}
description={t('tools.transcript.body')} description={t('tools.transcript.body')}
onPress={() => openTranscript()} onPress={() => openTranscript()}
isDisabled={encrypted}
/> />
)} )}
{isScreenRecordingEnabled && ( {isScreenRecordingEnabled && (
@@ -180,6 +214,7 @@ export const Tools = () => {
title={t('tools.screenRecording.title')} title={t('tools.screenRecording.title')}
description={t('tools.screenRecording.body')} description={t('tools.screenRecording.body')}
onPress={() => openScreenRecording()} onPress={() => openScreenRecording()}
isDisabled={encrypted}
/> />
)} )}
</Div> </Div>
@@ -37,7 +37,6 @@ export const ChatToggle = ({
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isChatOpen} isSelected={isChatOpen}
aria-expanded={isChatOpen}
onPress={(e) => { onPress={(e) => {
toggleChat() toggleChat()
onPress?.(e) onPress?.(e)
@@ -27,7 +27,6 @@ export const InfoToggle = ({
aria-label={t(tooltipLabel)} aria-label={t(tooltipLabel)}
tooltip={t(tooltipLabel)} tooltip={t(tooltipLabel)}
isSelected={isInfoOpen} isSelected={isInfoOpen}
aria-expanded={isInfoOpen}
onPress={(e) => { onPress={(e) => {
toggleInfo() toggleInfo()
onPress?.(e) onPress?.(e)
@@ -5,18 +5,22 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording' import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums' import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
export const ScreenRecordingMenuItem = () => { export const ScreenRecordingMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isScreenRecordingOpen, openScreenRecording, toggleTools } = const { isScreenRecordingOpen, openScreenRecording, toggleTools } =
useSidePanel() useSidePanel()
const roomData = useRoomData()
const hasScreenRecordingAccess = useHasRecordingAccess( const hasScreenRecordingAccess = useHasRecordingAccess(
RecordingMode.ScreenRecording, RecordingMode.ScreenRecording,
FeatureFlags.ScreenRecording FeatureFlags.ScreenRecording
) )
if (!hasScreenRecordingAccess) return null // Recording not available in encrypted rooms
if (!hasScreenRecordingAccess || checkEncryptedRoom(roomData)) return null
return ( return (
<MenuItem <MenuItem
@@ -5,17 +5,21 @@ import { menuRecipe } from '@/primitives/menuRecipe'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { RecordingMode, useHasRecordingAccess } from '@/features/recording' import { RecordingMode, useHasRecordingAccess } from '@/features/recording'
import { FeatureFlags } from '@/features/analytics/enums' import { FeatureFlags } from '@/features/analytics/enums'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isEncryptedRoom as checkEncryptedRoom } from '@/features/rooms/api/ApiRoom'
export const TranscriptMenuItem = () => { export const TranscriptMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' }) const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel() const { isTranscriptOpen, openTranscript, toggleTools } = useSidePanel()
const roomData = useRoomData()
const hasTranscriptAccess = useHasRecordingAccess( const hasTranscriptAccess = useHasRecordingAccess(
RecordingMode.Transcript, RecordingMode.Transcript,
FeatureFlags.Transcript FeatureFlags.Transcript
) )
if (!hasTranscriptAccess) return null // Recording/transcription not available in encrypted rooms
if (!hasTranscriptAccess || checkEncryptedRoom(roomData)) return null
return ( return (
<MenuItem <MenuItem

Some files were not shown because too many files have changed in this diff Show More