From 3698ac09eb41c0e1ce25cfe4d7539dc61b7931af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Rame=CC=81?= Date: Wed, 6 May 2026 09:49:21 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(all)=20implement=20the=20simplified?= =?UTF-8?q?=20and=20advanced=20modes=20of=20encryption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 86 +- docker/auth/realm.json | 23 +- src/backend/core/api/__init__.py | 6 + src/backend/core/api/serializers.py | 65 +- src/backend/core/api/viewsets.py | 62 +- .../0019_room_encryption_enabled.py | 20 + .../migrations/0020_room_encryption_mode.py | 51 + ..._resourceaccess_encrypted_symmetric_key.py | 23 + src/backend/core/models.py | 29 + src/backend/core/services/lobby.py | 96 +- src/backend/core/utils.py | 49 +- src/backend/meet/settings.py | 15 +- src/frontend/package-lock.json | 871 +++++++++++++++++- src/frontend/package.json | 8 +- src/frontend/src/App.tsx | 19 +- src/frontend/src/api/useConfig.ts | 5 + src/frontend/src/components/Avatar.tsx | 4 +- src/frontend/src/features/auth/api/ApiUser.ts | 3 +- .../encryption/EncryptedMeetingBanner.tsx | 181 ++++ .../features/encryption/EncryptionBadge.tsx | 82 ++ .../features/encryption/EncryptionContext.tsx | 10 + .../encryption/EncryptionIdentityDialog.tsx | 326 +++++++ .../encryption/EncryptionSetupOverlay.tsx | 118 +++ .../encryption/EncryptionTrustModal.tsx | 143 +++ .../encryption/HybridKeyDistributor.ts | 122 +++ .../src/features/encryption/SECURITY.md | 117 +++ .../encryption/VaultClientProvider.tsx | 231 +++++ .../encryption/VaultE2EEManager.test.ts | 396 ++++++++ .../features/encryption/VaultE2EEManager.ts | 328 +++++++ .../src/features/encryption/global.d.ts | 104 +++ src/frontend/src/features/encryption/index.ts | 17 + .../features/encryption/lobbyKeyExchange.ts | 49 + src/frontend/src/features/encryption/types.ts | 43 + .../encryption/useParticipantTrustLevel.ts | 133 +++ .../home/components/EncryptionModeDialog.tsx | 142 +++ .../home/components/JoinMeetingDialog.tsx | 124 ++- .../home/components/LaterMeetingDialog.tsx | 7 +- .../src/features/home/routes/Home.tsx | 96 +- .../WaitingParticipantNotification.tsx | 132 ++- .../src/features/rooms/api/ApiRoom.ts | 17 + .../src/features/rooms/api/createRoom.ts | 11 +- .../src/features/rooms/api/enterRoom.ts | 9 + .../rooms/api/listWaitingParticipants.ts | 4 + .../src/features/rooms/api/requestEntry.ts | 6 + .../features/rooms/components/Conference.tsx | 272 +++++- .../rooms/components/InviteDialog.tsx | 6 +- .../src/features/rooms/components/Join.tsx | 267 +++++- .../src/features/rooms/hooks/useLobby.ts | 23 +- .../rooms/hooks/useWaitingParticipants.ts | 115 ++- .../rooms/livekit/components/Admin.tsx | 25 +- .../rooms/livekit/components/Info.tsx | 14 +- .../livekit/components/ParticipantTile.tsx | 194 +++- .../rooms/livekit/components/Tools.tsx | 37 +- .../Options/ScreenRecordingMenuItem.tsx | 6 +- .../controls/Options/TranscriptMenuItem.tsx | 6 +- .../Participants/ParticipantListItem.tsx | 148 ++- .../WaitingParticipantListItem.tsx | 167 +++- .../livekit/hooks/useCopyRoomToClipboard.ts | 10 +- .../rooms/livekit/prefabs/VideoConference.tsx | 2 + .../src/features/rooms/utils/isRoomValid.ts | 12 +- .../sdk/routes/CreateMeetingButton.tsx | 26 +- .../src/features/sdk/routes/CreatePopup.tsx | 237 ++++- .../src/features/sdk/utils/PopupManager.ts | 4 +- .../src/features/sdk/utils/PopupWindow.ts | 2 +- src/frontend/src/features/sdk/utils/types.ts | 2 + .../settings/components/tabs/AccountTab.tsx | 34 +- src/frontend/src/layout/Header.tsx | 128 ++- src/frontend/src/locales/en/global.json | 3 + src/frontend/src/locales/en/home.json | 27 +- src/frontend/src/locales/en/rooms.json | 102 ++ src/frontend/src/locales/en/sdk.json | 8 + src/frontend/src/locales/en/settings.json | 3 +- src/frontend/src/locales/fr/global.json | 2 + src/frontend/src/locales/fr/home.json | 27 +- src/frontend/src/locales/fr/rooms.json | 102 ++ src/frontend/src/locales/fr/sdk.json | 8 + src/frontend/src/locales/fr/settings.json | 3 +- src/frontend/src/primitives/Field.tsx | 3 +- src/frontend/src/primitives/Radio.tsx | 5 + .../src/primitives/TooltipWrapper.tsx | 2 +- src/frontend/vite.config.ts | 7 +- 81 files changed, 6158 insertions(+), 264 deletions(-) create mode 100644 src/backend/core/migrations/0019_room_encryption_enabled.py create mode 100644 src/backend/core/migrations/0020_room_encryption_mode.py create mode 100644 src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py create mode 100644 src/frontend/src/features/encryption/EncryptedMeetingBanner.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionBadge.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionContext.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionIdentityDialog.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionSetupOverlay.tsx create mode 100644 src/frontend/src/features/encryption/EncryptionTrustModal.tsx create mode 100644 src/frontend/src/features/encryption/HybridKeyDistributor.ts create mode 100644 src/frontend/src/features/encryption/SECURITY.md create mode 100644 src/frontend/src/features/encryption/VaultClientProvider.tsx create mode 100644 src/frontend/src/features/encryption/VaultE2EEManager.test.ts create mode 100644 src/frontend/src/features/encryption/VaultE2EEManager.ts create mode 100644 src/frontend/src/features/encryption/global.d.ts create mode 100644 src/frontend/src/features/encryption/index.ts create mode 100644 src/frontend/src/features/encryption/lobbyKeyExchange.ts create mode 100644 src/frontend/src/features/encryption/types.ts create mode 100644 src/frontend/src/features/encryption/useParticipantTrustLevel.ts create mode 100644 src/frontend/src/features/home/components/EncryptionModeDialog.tsx diff --git a/README.md b/README.md index e372bf4e..a2a7bfd1 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ meet logo

-

@@ -12,11 +11,11 @@ GitHub closed issues GitHub closed issues - +

- LiveKit - Chat with us - Roadmap - Changelog - Bug reports + LiveKit - Chat with us - Roadmap - Changelog - Bug reports

@@ -28,25 +27,75 @@ ## 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/). + ### Features + - Optimized for stability in large meetings (+100 p.) - Support for multiple screen sharing streams - Non-persistent, secure chat -- End-to-end encryption (coming soon) +- End-to-end encryption with hybrid key distribution - Meeting recording - Meeting transcription & Summary (currently in beta) - Telephony integration - Secure participation with robust authentication and access control - Customizable frontend style - LiveKit Advances features including : - - speaker detection - - simulcast - - end-to-end optimizations + - speaker detection + - simulcast + - end-to-end optimizations - selective subscription - SVC codecs (VP9, AV1) +### End-to-end encryption -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 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). We’re continuously adding new features to enhance your experience, with the latest updates coming soon! @@ -63,7 +112,6 @@ On the 25th of January 2026, David Amiel, France’s Minister for Civil Service - [Philosophy](#philosophy) - [Open source](#open-source) - ## Get started ## 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. #### 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🙏 -| 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.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 | -| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | - +| 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.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 | +| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. | ## 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)) - 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 We’re 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. We’re 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. - ## Open-source Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE). @@ -121,14 +167,13 @@ 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). - ## Contributors 🧞 -## Credits +## Credits We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work! This project is tested with BrowserStack. @@ -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). Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html). - diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 2746c781..33447fa4 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -60,7 +60,7 @@ }, { "username": "user-e2e-chromium", - "email": "user@chromium.e2e", + "email": "user.test@chromium.test", "firstName": "E2E", "lastName": "Chromium", "enabled": "true", @@ -74,7 +74,7 @@ }, { "username": "user-e2e-webkit", - "email": "user@webkit.e2e", + "email": "user.test@webkit.test", "firstName": "E2E", "lastName": "Webkit", "enabled": "true", @@ -88,7 +88,7 @@ }, { "username": "user-e2e-firefox", - "email": "user@firefox.e2e", + "email": "user.test@firefox.test", "firstName": "E2E", "lastName": "Firefox", "enabled": "true", @@ -845,6 +845,23 @@ "offline_access", "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": [ diff --git a/src/backend/core/api/__init__.py b/src/backend/core/api/__init__.py index b212c5ad..a751ae17 100644 --- a/src/backend/core/api/__init__.py +++ b/src/backend/core/api/__init__.py @@ -73,5 +73,11 @@ def get_frontend_configuration(request): "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) return Response(frontend_configuration) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index f7db2125..4d52d373 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -30,8 +30,8 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name", "timezone", "language"] - read_only_fields = ["id", "email", "full_name", "short_name"] + fields = ["id", "sub", "email", "full_name", "short_name", "timezone", "language"] + read_only_fields = ["id", "sub", "email", "full_name", "short_name"] class UserLightSerializer(serializers.ModelSerializer): @@ -74,6 +74,23 @@ class ResourceAccessSerializerMixin: raise PermissionDenied( "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 def validate_resource(self, resource): @@ -98,7 +115,7 @@ class ResourceAccessSerializer( class Meta: model = models.ResourceAccess - fields = ["id", "user", "resource", "role"] + fields = ["id", "user", "resource", "role", "encrypted_symmetric_key"] read_only_fields = ["id"] def update(self, instance, validated_data): @@ -128,9 +145,27 @@ class RoomSerializer(serializers.ModelSerializer): class Meta: 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"] + 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): """ Add users only for administrator users. @@ -172,18 +207,34 @@ class RoomSerializer(serializers.ModelSerializer): if should_access_room: room_id = f"{instance.id!s}" 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( room_id=room_id, user=request.user, username=username, configuration=configuration, is_admin_or_owner=is_admin_or_owner, + encryption_mode=instance.encryption_mode, ) else: del output["pin_code"] 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 @@ -265,7 +316,8 @@ class StartRecordingSerializer(BaseValidationOnlySerializer): class RequestEntrySerializer(BaseValidationOnlySerializer): """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): @@ -273,6 +325,9 @@ class ParticipantEntrySerializer(BaseValidationOnlySerializer): participant_id = serializers.UUIDField(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): diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8375ccd1..495853d1 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -281,11 +281,32 @@ class RoomViewSet( def perform_create(self, serializer): """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() + encrypted_symmetric_key = self.request.data.get("encrypted_symmetric_key", "") models.ResourceAccess.objects.create( resource=room, user=self.request.user, role=models.RoleChoices.OWNER, + encrypted_symmetric_key=encrypted_symmetric_key, ) if callback_id := self.request.data.get("callback_id"): @@ -314,6 +335,12 @@ class RoomViewSet( options = serializer.validated_data.get("options") 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 recording = models.Recording.objects.create( room=room, @@ -396,12 +423,28 @@ class RoomViewSet( serializer.is_valid(raise_exception=True) 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() participant, livekit = lobby_service.request_entry( room=room, request=request, - **serializer.validated_data, + **validated_data, ) response = drf_response.Response({**participant.to_dict(), "livekit": livekit}) lobby_service.prepare_response(response, participant.id) @@ -437,6 +480,9 @@ class RoomViewSet( room_id=room.id, participant_id=str(serializer.validated_data.get("participant_id")), 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."}) @@ -464,6 +510,14 @@ class RoomViewSet( lobby_service = LobbyService() 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}) @decorators.action( @@ -566,6 +620,12 @@ class RoomViewSet( 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: SubtitleService().start_subtitle(room) except SubtitleException: diff --git a/src/backend/core/migrations/0019_room_encryption_enabled.py b/src/backend/core/migrations/0019_room_encryption_enabled.py new file mode 100644 index 00000000..ea571dbc --- /dev/null +++ b/src/backend/core/migrations/0019_room_encryption_enabled.py @@ -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", + ), + ), + ] diff --git a/src/backend/core/migrations/0020_room_encryption_mode.py b/src/backend/core/migrations/0020_room_encryption_mode.py new file mode 100644 index 00000000..02a295bb --- /dev/null +++ b/src/backend/core/migrations/0020_room_encryption_mode.py @@ -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", + ), + ] diff --git a/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py b/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py new file mode 100644 index 00000000..d71ba9ee --- /dev/null +++ b/src/backend/core/migrations/0021_resourceaccess_encrypted_symmetric_key.py @@ -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", + ), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 9e921ae5..7fa4e881 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -98,6 +98,14 @@ class RoomAccessLevel(models.TextChoices): 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): """ Serves as an abstract base model for other models, ensuring that records are validated @@ -324,6 +332,15 @@ class ResourceAccess(BaseModel): role = models.CharField( 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: db_table = "meet_resource_access" @@ -388,6 +405,13 @@ class Room(Resource): choices=RoomAccessLevel.choices, 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( blank=True, default=dict, @@ -442,6 +466,11 @@ class Room(Resource): """Check if a room is 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 def generate_unique_pin_code(length): """Generate a unique n-digit PIN code""" diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index 16fa2a3d..dee555a4 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -46,15 +46,36 @@ class LobbyParticipant: username: str color: 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]: """Serialize the participant object to a dict representation.""" - return { + result = { "status": self.status.value, "username": self.username, "id": self.id, "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 def from_dict(cls, data: dict) -> "LobbyParticipant": @@ -68,6 +89,13 @@ class LobbyParticipant: username=data["username"], id=data["id"], 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: logger.exception("Error creating Participant from dict:") @@ -99,7 +127,7 @@ class LobbyService: key=settings.LOBBY_COOKIE_NAME, value=participant_id, httponly=True, - secure=True, + secure=not settings.DEBUG, samesite="Lax", ) @@ -111,11 +139,16 @@ class LobbyService: 1. The room is public (open to everyone) 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. This function only checks the current state and should be called each time a participant requests entry to ensure consistent access control, even for participants who have already begun waiting. """ + if hasattr(room, 'encryption_mode') and room.encryption_mode != 'none': + return False return room.is_public or ( room.access_level == models.RoomAccessLevel.TRUSTED and user.is_authenticated @@ -126,6 +159,7 @@ class LobbyService: room, request, username: str, + ephemeral_public_key: str = '', ) -> Tuple[LobbyParticipant, Optional[Dict]]: """Request entry to a room for a participant. @@ -164,19 +198,42 @@ class LobbyService: configuration=room.configuration, is_admin_or_owner=False, participant_id=participant_id, + encryption_mode=room.encryption_mode, ) return participant, livekit_config livekit_config = 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: self.refresh_waiting_status(room.id, participant_id) 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( room_id=room_id, user=request.user, @@ -185,6 +242,7 @@ class LobbyService: configuration=room.configuration, is_admin_or_owner=False, participant_id=participant_id, + encryption_mode=room.encryption_mode, ) return participant, livekit_config @@ -201,7 +259,11 @@ class LobbyService: ) 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: """Add participant to waiting lobby. @@ -216,6 +278,10 @@ class LobbyService: username=username, id=participant_id, color=color, + is_authenticated=is_authenticated, + email=email, + suite_user_id=suite_user_id, + ephemeral_public_key=ephemeral_public_key, ) try: @@ -284,6 +350,9 @@ class LobbyService: room_id: UUID, participant_id: str, allow_entry: bool, + encrypted_key: str = '', + admin_ephemeral_public_key: str = '', + encrypted_vault_key: str = '', ) -> None: """Handle decision on participant entry. @@ -302,7 +371,13 @@ class LobbyService: "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( self, @@ -310,6 +385,9 @@ class LobbyService: participant_id: str, status: LobbyParticipantStatus, timeout: int, + encrypted_key: str = '', + admin_ephemeral_public_key: str = '', + encrypted_vault_key: str = '', ) -> None: """Update participant status with appropriate timeout.""" @@ -330,6 +408,12 @@ class LobbyService: raise 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) def clear_room_cache(self, room_id: UUID) -> None: diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index 3afba6bc..1b15f8eb 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -66,6 +66,7 @@ def generate_token( sources: Optional[List[str]] = None, is_admin_or_owner: bool = False, participant_id: Optional[str] = None, + encryption_mode: str = 'none', ) -> str: """Generate a LiveKit access token for a user in a specific room. @@ -92,11 +93,15 @@ def generate_token( if sources is None: 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( room=room, room_join=True, room_admin=is_admin_or_owner, - can_update_own_metadata=True, + can_update_own_metadata=can_update_metadata, can_publish=bool(sources), can_publish_sources=sources, can_subscribe=True, @@ -112,6 +117,42 @@ def generate_token( if color is None: 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 = ( AccessToken( api_key=settings.LIVEKIT_CONFIGURATION["api_key"], @@ -120,9 +161,7 @@ def generate_token( .with_grants(video_grants) .with_identity(identity) .with_name(username or default_username) - .with_attributes( - {"color": color, "room_admin": "true" if is_admin_or_owner else "false"} - ) + .with_attributes(attributes) ) return token.to_jwt() @@ -136,6 +175,7 @@ def generate_livekit_config( color: Optional[str] = None, configuration: Optional[dict] = None, participant_id: Optional[str] = None, + encryption_mode: str = 'none', ) -> dict: """Generate LiveKit configuration for room access. @@ -168,6 +208,7 @@ def generate_livekit_config( sources=sources, is_admin_or_owner=is_admin_or_owner, participant_id=participant_id, + encryption_mode=encryption_mode, ), } diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index fa689dfa..47540621 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -561,12 +561,12 @@ class Base(Configuration): "returnTo", environ_name="OIDC_REDIRECT_FIELD_NAME", environ_prefix=None ) OIDC_USERINFO_FULLNAME_FIELDS = values.ListValue( - default=["given_name", "usual_name"], + default=["first_name", "last_name"], environ_name="OIDC_USERINFO_FULLNAME_FIELDS", environ_prefix=None, ) OIDC_USERINFO_SHORTNAME_FIELD = values.Value( - default="given_name", + default="first_name", environ_name="OIDC_USERINFO_SHORTNAME_FIELD", environ_prefix=None, ) @@ -808,6 +808,17 @@ class Base(Configuration): 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 APPLICATION_CLIENT_ID_LENGTH = values.PositiveIntegerValue( 40, diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index a2add8ac..7b816625 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -54,13 +54,54 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-refresh": "0.4.20", + "jsdom": "^29.0.2", "postcss": "8.5.6", "prettier": "3.8.1", "typescript": "5.8.3", "vite": "7.3.1", - "vite-tsconfig-paths": "6.1.1" + "vite-tsconfig-paths": "6.1.1", + "vitest": "^4.1.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.6.tgz", + "integrity": "sha512-BXWCh8dHs9GOfpo/fWGDJtDmleta2VePN9rn6WQt3GjEbxzutVF4t0x2pmH+7dbMCLtuv3MlwqRsAuxlzFXqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.8.tgz", + "integrity": "sha512-erMO6FgtM02dC24NGm0xufMzWz5OF0wXKR7BpvGD973bq/GbmR8/DbxNZbj0YevQ5hlToJaWSVK/G9/NDgGEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -406,6 +447,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@bufbuild/protobuf": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz", @@ -435,6 +489,146 @@ "sisteransi": "^1.0.5" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@csstools/postcss-cascade-layers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz", @@ -1000,6 +1194,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", @@ -4308,6 +4520,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.13", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", @@ -4622,6 +4841,24 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dom-mediacapture-record": { "version": "1.0.22", "resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz", @@ -4985,6 +5222,129 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.25", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", @@ -5295,6 +5655,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5392,6 +5762,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bl": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", @@ -5750,6 +6130,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cheerio": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", @@ -6025,6 +6415,20 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", @@ -6074,6 +6478,30 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -6450,6 +6878,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -6930,6 +7365,16 @@ "node": ">=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -7662,6 +8107,19 @@ "resolved": "https://registry.npmjs.org/howler/-/howler-2.2.4.tgz", "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -8135,6 +8593,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -8281,6 +8746,103 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.2.tgz", + "integrity": "sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", + "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8878,6 +9440,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -9172,6 +9741,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -10281,6 +10861,19 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -10498,6 +11091,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -10551,6 +11151,13 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -10561,6 +11168,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-composer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/stream-composer/-/stream-composer-1.0.2.tgz", @@ -10700,6 +11314,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/symlink-or-copy": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/symlink-or-copy/-/symlink-or-copy-1.3.1.tgz", @@ -10777,6 +11398,23 @@ "xtend": "~4.0.1" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -10825,6 +11463,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10859,6 +11527,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -11921,6 +12615,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -11929,6 +12726,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", @@ -11970,6 +12780,16 @@ "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", "license": "Apache-2.0" }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, "node_modules/webrtc-adapter": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.1.tgz", @@ -12001,6 +12821,21 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -12050,6 +12885,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12088,6 +12940,23 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/src/frontend/package.json b/src/frontend/package.json index 10f33a68..e19d330e 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -10,7 +10,9 @@ "preview": "vite preview", "i18n:extract": "npx i18next -c i18next-parser.config.json", "format": "prettier --write ./src", - "check": "prettier --check ./src" + "check": "prettier --check ./src", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@fontsource-variable/material-symbols-outlined": "5.2.34", @@ -59,10 +61,12 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-refresh": "0.4.20", + "jsdom": "^29.0.2", "postcss": "8.5.6", "prettier": "3.8.1", "typescript": "5.8.3", "vite": "7.3.1", - "vite-tsconfig-paths": "6.1.1" + "vite-tsconfig-paths": "6.1.1", + "vitest": "^4.1.3" } } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 7921a2c8..4da1c8a6 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -14,6 +14,7 @@ import './i18n/init' import { queryClient } from '@/api/queryClient' import { AppInitialization } from '@/components/AppInitialization' import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext' +import { VaultClientProvider } from '@/features/encryption' function App() { const { i18n } = useTranslation() @@ -25,20 +26,22 @@ function App() { {!isSDKContext && } - - + + + {Object.entries(routes).map(([, route], i) => ( ))} - - - + + + + ) diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index e2a255e9..b621cfa7 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -52,6 +52,11 @@ export interface ApiConfig { enable_firefox_proxy_workaround: boolean default_sources: string[] } + encryption?: { + enabled: boolean + vault_url: string + interface_url: string + } transcription_destination?: string } diff --git a/src/frontend/src/components/Avatar.tsx b/src/frontend/src/components/Avatar.tsx index 168fa953..c4bd1073 100644 --- a/src/frontend/src/components/Avatar.tsx +++ b/src/frontend/src/components/Avatar.tsx @@ -57,7 +57,7 @@ export const Avatar = ({ style, ...props }: AvatarProps) => { - const initial = name?.trim()?.charAt(0) ?? '' + const initial = name?.trim()?.charAt(0)?.toUpperCase() ?? '' return (