diff --git a/backend/.env.example b/backend/.env.example index 45603c4..20a02fe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,15 @@ TARGET_ENVIRONMENT_NAME=local ANALYTICS_MONGODB_URI=mongodb+srv://:@/?retryWrites=true&w=majority&appName=CompassAnalytics-Dev ANALYTICS_DATABASE_NAME=compass-analytics-dev +# ---- Auth (Firebase) ---- +# Required in non-local environments. In local mode the JWT signature is not verified. +FIREBASE_PROJECT_ID= + +# ---- Per-service API keys (outbound, backend → external services) ---- +# Add a new variable here and a new ExternalService enum value when onboarding a new service. +COMPASS_API_KEY=change-me-before-deploying +COMPASS_BASE_URL=https://dev.compass.tabiya.tech/api + # ---- Observability ---- BACKEND_ENABLE_SENTRY=False BACKEND_SENTRY_DSN= diff --git a/backend/app/analytics/__init__.py b/backend/app/analytics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/analytics/dependencies.py b/backend/app/analytics/dependencies.py new file mode 100644 index 0000000..5bb5b26 --- /dev/null +++ b/backend/app/analytics/dependencies.py @@ -0,0 +1,34 @@ +import asyncio +import logging + +from app.analytics.repositories import CompassAnalyticsRepository +from app.analytics.services import AnalyticsService, IAnalyticsService +from app.app_config import get_application_config +from app.auth.api_key import ExternalService +from common_libs.http_client.base import AsyncHttpClient + +logger = logging.getLogger(__name__) + +_lock = asyncio.Lock() +_singleton: IAnalyticsService | None = None + + +async def get_analytics_service() -> IAnalyticsService: + global _singleton + if _singleton is None: + async with _lock: + if _singleton is None: + config = get_application_config() + api_key = config.service_api_keys[ExternalService.COMPASS] + http_client = AsyncHttpClient( + base_url=config.compass_base_url, + headers={"X-API-Key": api_key}, + ) + _singleton = AnalyticsService(repository=CompassAnalyticsRepository(http_client)) + return _singleton + + +def clear_analytics_service_cache() -> None: + """Test-only: reset the singleton so the next request gets a fresh instance.""" + global _singleton + _singleton = None diff --git a/backend/app/analytics/reach_test.py b/backend/app/analytics/reach_test.py new file mode 100644 index 0000000..c60a807 --- /dev/null +++ b/backend/app/analytics/reach_test.py @@ -0,0 +1,256 @@ +""" +End-to-end tests for GET /api/reach. + +Uses httpx.AsyncClient with ASGI transport so the Motor DB fixture and the +route handler share the same asyncio event loop (TestClient uses a sync thread +bridge which causes a different-loop error with Motor). + +Authentication uses local mode (TARGET_ENVIRONMENT_TYPE=local), so any +HS256-signed JWT is accepted without signature verification — same behaviour +as running the server locally. + +The Compass API is not available in tests, so we use httpx.MockTransport to +control what the repository's http client receives. Tests that expect empty +data simply let the transport raise a connection error (API unavailable). +""" +import json + +import httpx +import jwt as pyjwt +import pytest +from fastapi import FastAPI + +from app.analytics.dependencies import get_analytics_service +from app.analytics.repositories import CompassAnalyticsRepository +from app.analytics.routes import add_analytics_routes +from app.analytics.services import AnalyticsService +from app.analytics.types import ReachResponse, ReachSummary, TimeSeriesPoint +from app.auth.firebase import Authentication +from common_libs.http_client.base import AsyncHttpClient + +_TEST_SECRET = "test-secret-key-long-enough-for-hs256" # nosec B105 — HS256 signing key for forged test JWTs, not a credential + + +def _make_firebase_token(user_id: str = "u1", email: str = "user@example.com") -> str: + claims = { + "sub": user_id, + "email": email, + "name": "Test User", + "firebase": {"sign_in_provider": "password"}, + } + return pyjwt.encode(claims, key=_TEST_SECRET, algorithm="HS256") + + +_VALID_TOKEN = _make_firebase_token() +_AUTH_HEADER = {"Authorization": f"Bearer {_VALID_TOKEN}"} + +_STUB_REACH_PAYLOAD = { + "summary": { + "total_users": 5000, + "active_users_30d": 1200, + "total_logins": 20000, + "avg_logins_per_user": 4.0, + "avg_session_minutes": 18, + }, + "series": [ + {"label": "Jan", "cumulative": 5000, "added": 500, "new_users": 400, "returning": 100, "logins": 800}, + ], +} + + +def _make_mock_transport(payload: dict | None = None, status_code: int = 200): + """Returns an httpx transport that responds with the given payload, or raises ConnectError if payload is None.""" + if payload is None: + def handler(_request): + raise httpx.ConnectError("Compass API not available") + return httpx.MockTransport(handler) + + body = json.dumps(payload).encode() + def handler(_request): + return httpx.Response(status_code, content=body, headers={"content-type": "application/json"}) + return httpx.MockTransport(handler) + + +def _make_service(transport) -> AnalyticsService: + http_client = AsyncHttpClient.__new__(AsyncHttpClient) + http_client._client = httpx.AsyncClient(transport=transport, base_url="http://compass-mock") + return AnalyticsService(repository=CompassAnalyticsRepository(http_client)) + + +@pytest.fixture() +async def client_with_data(monkeypatch): + # GIVEN the server runs in local mode (no Firebase signature verification) + monkeypatch.setenv("TARGET_ENVIRONMENT_TYPE", "local") + + app = FastAPI() + auth = Authentication() + add_analytics_routes(app, auth) + + service = _make_service(_make_mock_transport(_STUB_REACH_PAYLOAD)) + app.dependency_overrides[get_analytics_service] = lambda: service + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: + yield c + + +@pytest.fixture() +async def client_no_api(monkeypatch): + # GIVEN the server runs in local mode and the Compass API is unreachable + monkeypatch.setenv("TARGET_ENVIRONMENT_TYPE", "local") + + app = FastAPI() + auth = Authentication() + add_analytics_routes(app, auth) + + service = _make_service(_make_mock_transport(None)) + app.dependency_overrides[get_analytics_service] = lambda: service + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c: + yield c + + +def _reach_url(start: str, end: str, granularity: str = "month", **kwargs) -> str: + params = f"start_date={start}&end_date={end}&granularity={granularity}" + for k, v in kwargs.items(): + params += f"&{k}={v}" + return f"/api/reach?{params}" + + +class TestReachAuth: + async def test_should_reject_request_with_no_auth_header(self, client_with_data): + # GIVEN no Authorization header is sent + + # WHEN the reach endpoint is called without a token + actual_response = await client_with_data.get(_reach_url("2026-01-01", "2026-06-30")) + + # THEN expect the request to be rejected with 401 + assert actual_response.status_code == 401 + + async def test_should_reject_request_with_an_invalid_token(self, client_with_data): + # GIVEN an invalid (non-JWT) bearer token + given_headers = {"Authorization": "Bearer not-a-jwt"} + + # WHEN the reach endpoint is called with the invalid token + actual_response = await client_with_data.get( + _reach_url("2026-01-01", "2026-06-30"), + headers=given_headers, + ) + + # THEN expect the request to be rejected with 401 + assert actual_response.status_code == 401 + + +class TestReachResponse: + async def test_should_return_200_with_valid_token_and_params(self, client_with_data): + # GIVEN a valid Firebase token and valid date range params + + # WHEN the reach endpoint is called + actual_response = await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER) + + # THEN expect a successful response + assert actual_response.status_code == 200 + + async def test_should_include_summary_and_series_in_response(self, client_with_data): + # GIVEN a valid request + + # WHEN the reach endpoint is called + actual_body = (await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER)).json() + + # THEN expect the response to contain both summary and series sections + assert "summary" in actual_body + assert "series" in actual_body + + async def test_should_include_all_required_summary_fields(self, client_with_data): + # GIVEN a valid request + + # WHEN the reach endpoint is called + actual_summary = ( + await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER) + ).json()["summary"] + + # THEN expect all required summary fields to be present + assert all( + k in actual_summary + for k in ("total_users", "active_users_30d", "total_logins", "avg_logins_per_user", "avg_session_minutes") + ) + + async def test_should_return_data_from_compass_api(self, client_with_data): + # GIVEN the Compass API returns stub data + + # WHEN the reach endpoint is called + actual_summary = ( + await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER) + ).json()["summary"] + + # THEN expect the summary values to match what the Compass API returned + assert actual_summary["total_users"] == 5000 + assert actual_summary["active_users_30d"] == 1200 + + async def test_should_include_all_required_fields_in_each_series_point(self, client_with_data): + # GIVEN a valid request + + # WHEN the reach endpoint is called + actual_series = ( + await client_with_data.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER) + ).json()["series"] + + # THEN expect at least one point in the series + assert len(actual_series) > 0 + # AND each point to have all required fields + for point in actual_series: + assert all(k in point for k in ("label", "cumulative", "added", "new_users", "returning", "logins")) + + +class TestReachWhenApiUnavailable: + async def test_should_return_200_with_empty_data_when_compass_api_is_unreachable(self, client_no_api): + # GIVEN the Compass API is unreachable + + # WHEN the reach endpoint is called + actual_response = await client_no_api.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER) + + # THEN expect a successful response (not an error) + assert actual_response.status_code == 200 + + async def test_should_return_zero_summary_when_compass_api_is_unreachable(self, client_no_api): + # GIVEN the Compass API is unreachable + + # WHEN the reach endpoint is called + actual_summary = ( + await client_no_api.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER) + ).json()["summary"] + + # THEN expect all summary values to be zero + assert actual_summary["total_users"] == 0 + assert actual_summary["total_logins"] == 0 + + async def test_should_return_empty_series_when_compass_api_is_unreachable(self, client_no_api): + # GIVEN the Compass API is unreachable + + # WHEN the reach endpoint is called + actual_series = ( + await client_no_api.get(_reach_url("2026-01-01", "2026-06-30"), headers=_AUTH_HEADER) + ).json()["series"] + + # THEN expect an empty series + assert actual_series == [] + + +class TestReachValidation: + async def test_should_return_422_when_required_params_are_missing(self, client_with_data): + # GIVEN no query parameters + + # WHEN the reach endpoint is called without required params + actual_response = await client_with_data.get("/api/reach", headers=_AUTH_HEADER) + + # THEN expect a validation error + assert actual_response.status_code == 422 + + async def test_should_return_422_for_invalid_granularity_value(self, client_with_data): + # GIVEN an unsupported granularity value + given_url = _reach_url("2026-01-01", "2026-06-30", granularity="quarter") + + # WHEN the reach endpoint is called with the invalid value + actual_response = await client_with_data.get(given_url, headers=_AUTH_HEADER) + + # THEN expect a validation error + assert actual_response.status_code == 422 diff --git a/backend/app/analytics/repositories.py b/backend/app/analytics/repositories.py new file mode 100644 index 0000000..231edbb --- /dev/null +++ b/backend/app/analytics/repositories.py @@ -0,0 +1,86 @@ +import logging +from abc import ABC, abstractmethod + +from app.analytics.types import ( + AnalyticsFilters, + ReachResponse, + ReachSummary, + TimeSeriesPoint, +) +from common_libs.http_client.base import AsyncHttpClient, HttpClientError + +logger = logging.getLogger(__name__) + + +class IAnalyticsRepository(ABC): + @abstractmethod + async def get_reach(self, institution_ids: list[str] | None, filters: AnalyticsFilters) -> ReachResponse: ... + + +class CompassAnalyticsRepository(IAnalyticsRepository): + """ + Fetches analytics data from the Compass API. + Returns empty/zero data if the API is unavailable or returns nothing — + the Compass API endpoints don't exist yet and will be added incrementally. + """ + + def __init__(self, http_client: AsyncHttpClient): + self._client = http_client + + async def get_reach(self, institution_ids: list[str] | None, filters: AnalyticsFilters) -> ReachResponse: + params: dict = { + "start_date": filters.start_date.isoformat(), + "end_date": filters.end_date.isoformat(), + "granularity": filters.granularity, + } + if institution_ids: + params["institution_ids"] = ",".join(institution_ids) + if filters.audience_segment: + params["audience_segment"] = filters.audience_segment + if filters.login_method: + params["login_method"] = filters.login_method + + try: + data = await self._client.get("/analytics/reach", params=params) + except HttpClientError as exc: + logger.warning("Compass API reach request failed (%s): %s", exc.status_code, exc) + data = None + except Exception as exc: # pylint: disable=broad-except + logger.warning("Compass API reach request error: %s", exc) + data = None + + if not data: + return ReachResponse( + summary=ReachSummary( + total_users=0, + active_users_30d=0, + total_logins=0, + avg_logins_per_user=0.0, + avg_session_minutes=0, + ), + series=[], + ) + + summary_raw = data.get("summary", {}) + series_raw = data.get("series", []) + + return ReachResponse( + summary=ReachSummary( + total_users=summary_raw.get("total_users", 0), + active_users_30d=summary_raw.get("active_users_30d", 0), + total_logins=summary_raw.get("total_logins", 0), + avg_logins_per_user=summary_raw.get("avg_logins_per_user", 0.0), + avg_session_minutes=summary_raw.get("avg_session_minutes", 0), + ), + series=[ + TimeSeriesPoint( + label=pt.get("label", ""), + cumulative=pt.get("cumulative", 0), + added=pt.get("added", 0), + new_users=pt.get("new_users", 0), + returning=pt.get("returning", 0), + logins=pt.get("logins", 0), + ) + for pt in series_raw + ], + ) diff --git a/backend/app/analytics/routes.py b/backend/app/analytics/routes.py new file mode 100644 index 0000000..4aa12e9 --- /dev/null +++ b/backend/app/analytics/routes.py @@ -0,0 +1,56 @@ +import logging +from datetime import date + +from fastapi import APIRouter, Depends, FastAPI, Query + +from app.analytics.dependencies import get_analytics_service +from app.analytics.services import IAnalyticsService +from app.analytics.types import ( + AnalyticsFilters, + AudienceSegment, + Granularity, + LoginMethod, + ReachResponse, +) +from app.auth.firebase import Authentication, UserInfo + +logger = logging.getLogger(__name__) + +_API_PREFIX = "/api" + + +def _filters( + start_date: date = Query(..., description="Inclusive start date (yyyy-MM-dd)"), + end_date: date = Query(..., description="Inclusive end date (yyyy-MM-dd)"), + granularity: Granularity = Query(..., description="Time bucket size"), + audience_segment: AudienceSegment | None = Query(None), + login_method: LoginMethod | None = Query(None), + institution_id: str | None = Query(None, description="Drill down to a single institution"), +) -> AnalyticsFilters: + return AnalyticsFilters( + start_date=start_date, + end_date=end_date, + granularity=granularity, + audience_segment=audience_segment, + login_method=login_method, + institution_id=institution_id, + ) + + +def add_analytics_routes(app: FastAPI, auth: Authentication) -> None: + get_user_info = auth.get_user_info() + + router = APIRouter( + prefix=_API_PREFIX, + tags=["Analytics"], + ) + + @router.get("/reach", response_model=ReachResponse) + async def get_reach( + filters: AnalyticsFilters = Depends(_filters), + service: IAnalyticsService = Depends(get_analytics_service), + user_info: UserInfo = Depends(get_user_info), + ) -> ReachResponse: + return await service.get_reach(filters) + + app.include_router(router) diff --git a/backend/app/analytics/services.py b/backend/app/analytics/services.py new file mode 100644 index 0000000..fa14b92 --- /dev/null +++ b/backend/app/analytics/services.py @@ -0,0 +1,24 @@ +import logging +from abc import ABC, abstractmethod + +from app.analytics.repositories import IAnalyticsRepository +from app.analytics.types import AnalyticsFilters, ReachResponse + +logger = logging.getLogger(__name__) + + +class IAnalyticsService(ABC): + @abstractmethod + async def get_reach(self, filters: AnalyticsFilters) -> ReachResponse: ... + + +class AnalyticsService(IAnalyticsService): + def __init__(self, repository: IAnalyticsRepository): + self._repo = repository + + async def get_reach(self, filters: AnalyticsFilters) -> ReachResponse: + # institution_id in filters already carries any drill-down scope the + # frontend specifies. Access control (which institutions a caller may + # see) will be enforced here once we have a real user identity model. + institution_ids = [filters.institution_id] if filters.institution_id else None + return await self._repo.get_reach(institution_ids, filters) diff --git a/backend/app/analytics/types.py b/backend/app/analytics/types.py new file mode 100644 index 0000000..45babca --- /dev/null +++ b/backend/app/analytics/types.py @@ -0,0 +1,45 @@ +from datetime import date +from typing import Literal + +from pydantic import BaseModel + +# ---- Query filters ---- + +Granularity = Literal["day", "week", "month"] +AudienceSegment = Literal["youth", "women", "rural", "first-time-jobseeker"] +LoginMethod = Literal["email", "google", "anonymous"] + + +class AnalyticsFilters(BaseModel): + start_date: date + end_date: date + granularity: Granularity + audience_segment: AudienceSegment | None = None + login_method: LoginMethod | None = None + institution_id: str | None = None # None = all institutions in the caller's scope + + model_config = {"extra": "forbid"} + + +# ---- Reach ---- + +class ReachSummary(BaseModel): + total_users: int + active_users_30d: int + total_logins: int + avg_logins_per_user: float + avg_session_minutes: int + + +class TimeSeriesPoint(BaseModel): + label: str + cumulative: int + added: int + new_users: int + returning: int + logins: int + + +class ReachResponse(BaseModel): + summary: ReachSummary + series: list[TimeSeriesPoint] diff --git a/backend/app/app_config.py b/backend/app/app_config.py index 001c390..8fa26d0 100644 --- a/backend/app/app_config.py +++ b/backend/app/app_config.py @@ -2,6 +2,7 @@ from pydantic import BaseModel +from app.auth.api_key import ExternalService from app.version.types import VersionInfo @@ -29,6 +30,10 @@ class ApplicationConfig(BaseModel): analytics_mongodb_uri: str analytics_database_name: str + firebase_project_id: Optional[str] = None + service_api_keys: dict[ExternalService, str] + compass_base_url: str + _application_config: Optional[ApplicationConfig] = None diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/api_key.py b/backend/app/auth/api_key.py new file mode 100644 index 0000000..8fac1d4 --- /dev/null +++ b/backend/app/auth/api_key.py @@ -0,0 +1,59 @@ +import logging +import secrets +from enum import Enum +from typing import Callable + +from fastapi import Depends, HTTPException +from fastapi.security import APIKeyHeader + +logger = logging.getLogger(__name__) + +_API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=True) + + +class ExternalService(str, Enum): + COMPASS = "compass" + + +class ApiKeyAuth: + """ + Validates the X-API-Key header against a registry of per-service secrets. + + Each external service that calls this backend is assigned its own key, + loaded from environment variables at startup. The registry maps an + ExternalService to its secret so callers can also look up a key by service + (e.g. when this backend calls out to Compass as a client). + + Usage as a route dependency: + Depends(api_key_auth.require()) + + Usage for outbound calls: + api_key_auth.key_for(ExternalService.COMPASS) + """ + + def __init__(self, keys: dict[ExternalService, str]): + if not keys: + raise ValueError("At least one service API key must be configured.") + for service, key in keys.items(): + if not key: + raise ValueError(f"API key for service '{service}' must not be empty.") + self._keys = keys + # Pre-build the set of valid keys for O(n) lookup; n is tiny. + self._valid_keys = set(keys.values()) + + def require(self) -> Callable: + """FastAPI dependency — rejects requests whose X-API-Key is not in the registry.""" + def _check(key: str = Depends(_API_KEY_HEADER)) -> None: + if not any(secrets.compare_digest(key, valid) for valid in self._valid_keys): + logger.warning("Rejected request with invalid API key.") + raise HTTPException(status_code=401, detail="Invalid API key.") + + return _check + + + def key_for(self, service: ExternalService) -> str: + """Return the configured key for a specific service (for outbound requests).""" + key = self._keys.get(service) + if not key: + raise KeyError(f"No API key configured for service '{service}'.") + return key diff --git a/backend/app/auth/api_key_test.py b/backend/app/auth/api_key_test.py new file mode 100644 index 0000000..86cab25 --- /dev/null +++ b/backend/app/auth/api_key_test.py @@ -0,0 +1,93 @@ +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from app.auth.api_key import ApiKeyAuth, ExternalService + +_COMPASS_KEY = "compass-secret" +_KEYS = {ExternalService.COMPASS: _COMPASS_KEY} + + +@pytest.fixture() +def client() -> TestClient: + app = FastAPI() + auth = ApiKeyAuth(keys=_KEYS) + + @app.get("/protected") + def protected(_: None = Depends(auth.require())): + return {"ok": True} + + return TestClient(app, raise_server_exceptions=True) + + +class TestApiKeyAuthRequire: + def test_should_allow_request_with_a_valid_key(self, client): + # GIVEN a valid API key + given_key = _COMPASS_KEY + + # WHEN the protected endpoint is called with the given key + actual_response = client.get("/protected", headers={"X-API-Key": given_key}) + + # THEN expect the request to be allowed + assert actual_response.status_code == 200 + + def test_should_reject_request_with_a_wrong_key(self, client): + # GIVEN an invalid API key + given_key = "wrong-key" + + # WHEN the protected endpoint is called with the given key + actual_response = client.get("/protected", headers={"X-API-Key": given_key}) + + # THEN expect the request to be rejected with 401 + assert actual_response.status_code == 401 + + def test_should_reject_request_with_no_key_header(self, client): + # GIVEN no API key header is sent + + # WHEN the protected endpoint is called without the header + actual_response = client.get("/protected") + + # THEN expect the request to be rejected with 401 + assert actual_response.status_code == 401 + + +class TestApiKeyAuthKeyFor: + def test_should_return_the_key_for_a_known_service(self): + # GIVEN an auth instance configured with a Compass key + given_auth = ApiKeyAuth(keys=_KEYS) + + # WHEN the key for the Compass service is requested + actual_key = given_auth.key_for(ExternalService.COMPASS) + + # THEN expect the returned key to match the configured key + assert actual_key == _COMPASS_KEY + + def test_should_raise_for_a_service_with_no_configured_key(self): + # GIVEN an auth instance whose internal key registry has been cleared + # (simulates a service added to the enum but not yet configured in the environment) + given_auth = ApiKeyAuth(keys=_KEYS) + given_auth._keys = {} + + # WHEN the key for the Compass service is requested + # THEN expect a KeyError to be raised + with pytest.raises(KeyError): + given_auth.key_for(ExternalService.COMPASS) + + +class TestApiKeyAuthValidation: + def test_should_raise_when_constructed_with_an_empty_keys_dict(self): + # GIVEN an empty keys dict + + # WHEN ApiKeyAuth is constructed with no keys + # THEN expect a ValueError to be raised + with pytest.raises(ValueError, match="At least one"): + ApiKeyAuth(keys={}) + + def test_should_raise_when_a_service_key_value_is_empty(self): + # GIVEN a keys dict with an empty value for Compass + given_keys = {ExternalService.COMPASS: ""} + + # WHEN ApiKeyAuth is constructed with the given keys + # THEN expect a ValueError to be raised + with pytest.raises(ValueError, match="must not be empty"): + ApiKeyAuth(keys=given_keys) diff --git a/backend/app/auth/firebase.py b/backend/app/auth/firebase.py new file mode 100644 index 0000000..9351fb8 --- /dev/null +++ b/backend/app/auth/firebase.py @@ -0,0 +1,88 @@ +import logging +import os +from enum import Enum +from typing import Any, Callable, Optional + +import jwt +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +_BEARER = HTTPBearer(scheme_name="firebase") + + +class SignInProvider(str, Enum): + ANONYMOUS = "anonymous" + PASSWORD = "password" # nosec + GOOGLE = "google.com" + + +class UserInfo(BaseModel): + user_id: str + name: Optional[str] = None + email: Optional[str] = None + token: str + sign_in_provider: SignInProvider + + model_config = {"extra": "forbid"} + + +def _get_user_info(decoded_token: Any, token: str) -> UserInfo: + return UserInfo( + user_id=decoded_token["sub"], + name=decoded_token.get("name"), + email=decoded_token.get("email"), + token=token, + sign_in_provider=decoded_token["firebase"]["sign_in_provider"], + ) + + +def _verify_firebase_token(token: str, project_id: str) -> dict: + from google.auth.transport import requests as google_requests # type: ignore[import-untyped] + from google.oauth2 import id_token as google_id_token # type: ignore[import-untyped] + + return google_id_token.verify_firebase_token(token, google_requests.Request(), audience=project_id) + + +class Authentication: + """ + Mirrors the compass Authentication class. + + - Local: decodes the Firebase JWT from the Authorization header without + verifying the signature, so developers can use self-signed tokens. + - All other environments: verifies the token against Firebase's public keys + using google-auth. + """ + + def __init__(self, firebase_project_id: Optional[str] = None): + self.provider = _BEARER + self._firebase_project_id = firebase_project_id + + def get_user_info(self) -> Callable[[Request, HTTPAuthorizationCredentials], UserInfo]: + def construct_user_info( + request: Request, # noqa: ARG001 + credentials: HTTPAuthorizationCredentials = Depends(self.provider), + ) -> UserInfo: + target_env = os.getenv("TARGET_ENVIRONMENT_TYPE") + token = credentials.credentials + try: + if target_env == "local": + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized, missing credentials") + token_info = jwt.decode(token, options={"verify_signature": False}) + else: + if not self._firebase_project_id: + raise ValueError("FIREBASE_PROJECT_ID is required in non-local environments.") + token_info = _verify_firebase_token(token, self._firebase_project_id) + + return _get_user_info(token_info, token) + + except HTTPException: + raise + except Exception as exc: + logger.warning("Error while getting user info: %s - %s", exc.__class__.__name__, exc) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") + + return construct_user_info diff --git a/backend/app/auth/firebase_test.py b/backend/app/auth/firebase_test.py new file mode 100644 index 0000000..470df60 --- /dev/null +++ b/backend/app/auth/firebase_test.py @@ -0,0 +1,118 @@ +import jwt as pyjwt +import pytest + +from app.auth.firebase import Authentication, SignInProvider, _get_user_info + +_TEST_SECRET = "test-secret-key-long-enough-for-hs256" # nosec B105 — HS256 signing key for forged test JWTs, not a credential + + +def _make_token(claims: dict) -> str: + return pyjwt.encode(claims, key=_TEST_SECRET, algorithm="HS256") + + +class TestGetUserInfo: + def test_should_map_standard_firebase_claims_to_user_info(self): + # GIVEN a decoded Firebase token with standard claims + given_token = _make_token({ + "sub": "uid-1", + "email": "alice@example.com", + "name": "Alice", + "firebase": {"sign_in_provider": "password"}, + }) + given_claims = { + "sub": "uid-1", + "email": "alice@example.com", + "name": "Alice", + "firebase": {"sign_in_provider": "password"}, + } + + # WHEN the user info is extracted from the decoded token + actual_user_info = _get_user_info(given_claims, given_token) + + # THEN expect the user info to match the token claims + assert actual_user_info.user_id == "uid-1" + assert actual_user_info.email == "alice@example.com" + assert actual_user_info.name == "Alice" + assert actual_user_info.sign_in_provider == SignInProvider.PASSWORD + + def test_should_return_none_for_email_and_name_when_user_is_anonymous(self): + # GIVEN a decoded Firebase token for an anonymous user (no email or name) + given_claims = {"sub": "anon-1", "firebase": {"sign_in_provider": "anonymous"}} + given_token = _make_token(given_claims) + + # WHEN the user info is extracted from the decoded token + actual_user_info = _get_user_info(given_claims, given_token) + + # THEN expect the email and name to be None + assert actual_user_info.email is None + assert actual_user_info.name is None + # AND the sign-in provider to be anonymous + assert actual_user_info.sign_in_provider == SignInProvider.ANONYMOUS + + def test_should_map_google_sign_in_provider(self): + # GIVEN a decoded Firebase token from a Google sign-in + given_claims = { + "sub": "g-1", + "email": "g@gmail.com", + "name": "G User", + "firebase": {"sign_in_provider": "google.com"}, + } + given_token = _make_token(given_claims) + + # WHEN the user info is extracted from the decoded token + actual_user_info = _get_user_info(given_claims, given_token) + + # THEN expect the sign-in provider to be Google + assert actual_user_info.sign_in_provider == SignInProvider.GOOGLE + + +class TestAuthenticationLocal: + def test_should_decode_jwt_without_signature_verification_in_local_mode(self, monkeypatch): + # GIVEN the environment is set to local + monkeypatch.setenv("TARGET_ENVIRONMENT_TYPE", "local") + # AND a JWT token signed with a test secret + given_claims = { + "sub": "uid-local", + "email": "local@example.com", + "name": "Local User", + "firebase": {"sign_in_provider": "password"}, + } + given_token = _make_token(given_claims) + + # WHEN the token is decoded without signature verification + actual_decoded = pyjwt.decode(given_token, options={"verify_signature": False}) + + # THEN expect the decoded claims to match the original claims + assert actual_decoded["sub"] == "uid-local" + + def test_should_store_firebase_project_id_when_provided(self, monkeypatch): + # GIVEN a non-local environment + monkeypatch.setenv("TARGET_ENVIRONMENT_TYPE", "staging") + # AND a Firebase project ID + given_project_id = "my-project" + + # WHEN Authentication is constructed with the given project ID + actual_auth = Authentication(firebase_project_id=given_project_id) + + # THEN expect the project ID to be stored + assert actual_auth._firebase_project_id == given_project_id + + +class TestAuthenticationProduction: + def test_should_call_firebase_token_verification_in_production_mode(self, monkeypatch, mocker): + # GIVEN a non-local environment + monkeypatch.setenv("TARGET_ENVIRONMENT_TYPE", "staging") + # AND a fake decoded token returned by the Firebase verifier + given_claims = { + "sub": "uid-prod", + "email": "prod@example.com", + "firebase": {"sign_in_provider": "google.com"}, + } + mocker.patch("app.auth.firebase._verify_firebase_token", return_value=given_claims) + + # WHEN the Firebase token verifier is called + from app.auth.firebase import _verify_firebase_token + actual_claims = _verify_firebase_token("fake-token", "my-project") + + # THEN expect the returned claims to match the fake decoded token + assert actual_claims["sub"] == "uid-prod" diff --git a/backend/app/server.py b/backend/app/server.py index 27d4115..84e8a59 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -9,7 +9,10 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.analytics.routes import add_analytics_routes from app.app_config import ApplicationConfig, set_application_config +from app.auth.api_key import ApiKeyAuth, ExternalService +from app.auth.firebase import Authentication from app.sentry_init import init_sentry, set_sentry_contexts from app.server_dependencies.db_dependencies import AnalyticsDBProvider from app.version.routes import add_version_routes @@ -69,6 +72,11 @@ def _build_application_config() -> ApplicationConfig: sentry_config=sentry_config, analytics_mongodb_uri=analytics_mongodb_uri, analytics_database_name=analytics_database_name, + firebase_project_id=os.getenv("FIREBASE_PROJECT_ID") or None, + service_api_keys={ + ExternalService.COMPASS: _require_env("COMPASS_API_KEY"), + }, + compass_base_url=_require_env("COMPASS_BASE_URL"), ) @@ -110,7 +118,11 @@ async def lifespan(_app: FastAPI): allow_headers=["*"], ) +api_key_auth = ApiKeyAuth(keys=application_config.service_api_keys) +firebase_auth = Authentication(firebase_project_id=application_config.firebase_project_id) + add_version_routes(app) +add_analytics_routes(app, firebase_auth) if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8080) # nosec B104 # this will be run in a container diff --git a/backend/app/version/routes_test.py b/backend/app/version/routes_test.py index db43468..d55a98f 100644 --- a/backend/app/version/routes_test.py +++ b/backend/app/version/routes_test.py @@ -4,6 +4,7 @@ from fastapi.testclient import TestClient from app.app_config import ApplicationConfig, clear_application_config, set_application_config +from app.auth.api_key import ExternalService from app.version.routes import add_version_routes from app.version.types import VersionInfo @@ -20,6 +21,8 @@ def setup_method(self): enable_sentry=False, analytics_mongodb_uri="mongodb://localhost:27017", analytics_database_name="test", + service_api_keys={ExternalService.COMPASS: "test-compass-api-key"}, + compass_base_url="http://localhost:9999", ) ) diff --git a/backend/common_libs/http_client/__init__.py b/backend/common_libs/http_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/common_libs/http_client/base.py b/backend/common_libs/http_client/base.py new file mode 100644 index 0000000..c661305 --- /dev/null +++ b/backend/common_libs/http_client/base.py @@ -0,0 +1,54 @@ +import logging +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + + +class HttpClientError(Exception): + def __init__(self, status_code: int, message: str): + super().__init__(message) + self.status_code = status_code + + +class AsyncHttpClient: + """ + Thin async wrapper around httpx.AsyncClient. Raises HttpClientError on non-2xx + responses so callers don't have to inspect status codes themselves. + + Intended as the base for future external API clients (e.g. CompassApiClient). + """ + + def __init__(self, base_url: str, headers: dict[str, str] | None = None, timeout: float = 30.0): + self._client = httpx.AsyncClient( + base_url=base_url, + headers=headers or {}, + timeout=timeout, + ) + + async def get(self, path: str, params: dict[str, Any] | None = None) -> Any: + response = await self._client.get(path, params=params) + return self._handle_response(response) + + async def post(self, path: str, body: dict[str, Any] | None = None) -> Any: + response = await self._client.post(path, json=body) + return self._handle_response(response) + + async def close(self) -> None: + await self._client.aclose() + + @staticmethod + def _handle_response(response: httpx.Response) -> Any: + if response.is_success: + return response.json() if response.content else None + raise HttpClientError( + status_code=response.status_code, + message=f"HTTP {response.status_code}: {response.text[:200]}", + ) + + async def __aenter__(self) -> "AsyncHttpClient": + return self + + async def __aexit__(self, *_: Any) -> None: + await self.close() diff --git a/backend/conftest.py b/backend/conftest.py index 2128203..30d2fe5 100644 --- a/backend/conftest.py +++ b/backend/conftest.py @@ -13,6 +13,7 @@ from pymongo_inmemory.context import Context from app.app_config import ApplicationConfig, clear_application_config, set_application_config +from app.auth.api_key import ExternalService from app.server_dependencies.db_dependencies import AnalyticsDBProvider from app.version.types import VersionInfo @@ -86,6 +87,8 @@ def setup_application_config(): enable_sentry=False, analytics_mongodb_uri="mongodb://localhost:27017", analytics_database_name="test", + service_api_keys={ExternalService.COMPASS: "test-compass-api-key"}, + compass_base_url="http://localhost:9999", ) set_application_config(config) yield config diff --git a/backend/poetry.lock b/backend/poetry.lock index c210e03..a3550cd 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -92,6 +92,120 @@ files = [ {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] +[[package]] +name = "cffi" +version = "2.1.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"}, + {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"}, + {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"}, + {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"}, + {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"}, + {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"}, + {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"}, + {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"}, + {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"}, + {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"}, + {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"}, + {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"}, + {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"}, + {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"}, + {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"}, + {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"}, + {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"}, + {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"}, + {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"}, + {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"}, + {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"}, + {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"}, + {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"}, + {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"}, + {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "click" version = "8.4.2" @@ -120,6 +234,68 @@ files = [ ] markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} +[[package]] +name = "cryptography" +version = "50.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.9" +groups = ["main"] +files = [ + {file = "cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169"}, + {file = "cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105"}, + {file = "cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef"}, + {file = "cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30"}, + {file = "cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d"}, + {file = "cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c"}, + {file = "cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95"}, + {file = "cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269"}, + {file = "cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708"}, + {file = "cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9"}, + {file = "cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7"}, + {file = "cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437"}, + {file = "cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9"}, + {file = "cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +ssh = ["bcrypt (>=3.1.5)"] + [[package]] name = "dill" version = "0.4.1" @@ -181,6 +357,38 @@ all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (> standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "fastar (>=0.9.0)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +[[package]] +name = "google-auth" +version = "2.56.2" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6"}, + {file = "google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051"}, +] + +[package.dependencies] +cryptography = [ + {version = ">=38.0.3", markers = "python_version < \"3.14\""}, + {version = ">=41.0.5", markers = "python_version >= \"3.14\""}, +] +pyasn1-modules = ">=0.2.1" + +[package.extras] +aiohttp = ["aiohttp (>=3.8.0,<4.0.0) ; python_version < \"3.14\"", "aiohttp (>=3.9.0,<4.0.0) ; python_version >= \"3.14\"", "requests (>=2.30.0,<3.0.0)"] +cryptography = ["cryptography (>=38.0.3) ; python_version < \"3.14\"", "cryptography (>=41.0.5) ; python_version >= \"3.14\""] +enterprise-cert = ["cryptography (>=38.0.3) ; python_version < \"3.14\"", "cryptography (>=41.0.5) ; python_version >= \"3.14\""] +grpc = ["grpcio (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] +pyjwt = ["pyjwt (>=2.0)"] +pyopenssl = ["cryptography (>=38.0.3) ; python_version < \"3.14\"", "cryptography (>=41.0.5) ; python_version >= \"3.14\""] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.30.0,<3.0.0)"] +rsa = ["rsa (>=4.0.0,<5)"] +testing = ["aiohttp (>=3.8.0,<4.0.0) ; python_version < \"3.14\"", "aiohttp (>=3.9.0,<4.0.0) ; python_version >= \"3.14\"", "aioresponses", "flask", "freezegun", "grpcio (>=1.59.0,<2.0.0) ; python_version < \"3.14\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "packaging (>=20.0)", "pyjwt (>=2.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.30.0,<3.0.0)", "responses", "urllib3 (>=1.26.15,<3.0.0)"] +urllib3 = ["packaging (>=20.0)", "urllib3 (>=1.26.15,<3.0.0)"] + [[package]] name = "h11" version = "0.16.0" @@ -395,6 +603,46 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pyasn1" +version = "0.6.4" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b"}, + {file = "pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + +[package.dependencies] +pyasn1 = ">=0.6.1,<0.7.0" + +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -1139,4 +1387,4 @@ standard = ["httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", " [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "9961209edcf3a2ea2cf4f2b9dfdfdce7fe094847eca7408a7777fe1150dff6b4" +content-hash = "0488c454b3d4320ad6dcef4948ddd77c483307881792299a17120383ce9360af" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6065ede..b80c4bc 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pyjwt (>=2.13.0,<3.0.0)", "sentry-sdk[fastapi] (>=2.66.0,<3.0.0)", "httpx (>=0.28.1,<0.29.0)", + "google-auth (>=2.0.0,<3.0.0)", "pyyaml (>=6.0.3,<7.0.0)", ] diff --git a/frontend/.env.example b/frontend/.env.example index 3a135c0..1a0816e 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,6 +3,11 @@ # Vite only exposes variables prefixed with VITE_ to the client bundle # (via import.meta.env.*). +# ---- Firebase Auth ---- +VITE_FIREBASE_API_KEY= +VITE_FIREBASE_AUTH_DOMAIN= +VITE_FIREBASE_PROJECT_ID= + # ---- Observability ---- VITE_SENTRY_ENABLED=false VITE_SENTRY_DSN= diff --git a/frontend/package.json b/frontend/package.json index 8b37076..0bc2d95 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@tailwindcss/vite": "^4.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "firebase": "^12.17.0", "i18next": "^26.3.6", "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^1.25.0", diff --git a/frontend/src/_test_utilities/test-utils.tsx b/frontend/src/_test_utilities/test-utils.tsx index cc94be9..205c5cf 100644 --- a/frontend/src/_test_utilities/test-utils.tsx +++ b/frontend/src/_test_utilities/test-utils.tsx @@ -2,13 +2,16 @@ import type { ReactElement, ReactNode } from "react"; import { render as rtlRender, type RenderOptions } from "@testing-library/react"; import { HashRouter } from "react-router-dom"; import { AccessProvider } from "@/access/AccessContext"; +import { AuthProvider } from "@/auth/AuthContext"; // Session-wide providers only. Filter tests mount their own FiltersProvider, with a fixed date. export const AllTheProviders = ({ children }: Readonly<{ children: ReactNode }>) => { return ( - - {children} - + + + {children} + + ); }; diff --git a/frontend/src/analytics/Analytics.service.ts b/frontend/src/analytics/Analytics.service.ts new file mode 100644 index 0000000..f2cd669 --- /dev/null +++ b/frontend/src/analytics/Analytics.service.ts @@ -0,0 +1,50 @@ +import type { AnalyticsParams, ReachResponse } from "@/analytics/analytics.types"; + +export const ANALYTICS_API_BASE = "/api"; + +export class AnalyticsApiError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = "AnalyticsApiError"; + this.status = status; + } +} + +export class AnalyticsService { + private static instance: AnalyticsService | null = null; + + static getInstance(): AnalyticsService { + AnalyticsService.instance ??= new AnalyticsService(); + return AnalyticsService.instance; + } + + private async _fetch(path: string, token: string, params?: Record): Promise { + const url = new URL(`${ANALYTICS_API_BASE}${path}`, window.location.origin); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) url.searchParams.set(key, value); + } + } + + const response = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!response.ok) { + throw new AnalyticsApiError(response.status, `Analytics API error: ${response.status}`); + } + return response.json() as Promise; + } + + async getReach(params: AnalyticsParams, token: string): Promise { + return this._fetch("/reach", token, { + start_date: params.start_date, + end_date: params.end_date, + granularity: params.granularity, + audience_segment: params.audience_segment, + login_method: params.login_method, + institution_id: params.institution_id, + }); + } +} diff --git a/frontend/src/analytics/analytics.types.ts b/frontend/src/analytics/analytics.types.ts new file mode 100644 index 0000000..186f080 --- /dev/null +++ b/frontend/src/analytics/analytics.types.ts @@ -0,0 +1,34 @@ +export type Granularity = "day" | "week" | "month"; +export type AudienceSegment = "youth" | "women" | "rural" | "first-time-jobseeker"; +export type LoginMethod = "email" | "google" | "anonymous"; + +export interface AnalyticsParams { + start_date: string; + end_date: string; + granularity: Granularity; + audience_segment?: AudienceSegment; + login_method?: LoginMethod; + institution_id?: string; +} + +export interface ReachSummary { + total_users: number; + active_users_30d: number; + total_logins: number; + avg_logins_per_user: number; + avg_session_minutes: number; +} + +export interface TimeSeriesPoint { + label: string; + cumulative: number; + added: number; + new_users: number; + returning: number; + logins: number; +} + +export interface ReachResponse { + summary: ReachSummary; + series: TimeSeriesPoint[]; +} diff --git a/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx index 2ff4e0a..38aa657 100644 --- a/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/app/ProtectedRoute/ProtectedRoute.tsx @@ -1,10 +1,17 @@ +import { Navigate } from "react-router-dom"; import { type ReactNode } from "react"; +import { useAuth } from "@/auth/AuthContext"; +import { routerPaths } from "@/app/routerPaths"; interface ProtectedRouteProps { children: ReactNode; } const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const { user, loading } = useAuth(); + + if (loading) return null; + if (!user) return ; return <>{children}; }; diff --git a/frontend/src/app/index.tsx b/frontend/src/app/index.tsx index 09d6c11..ee1c8cc 100644 --- a/frontend/src/app/index.tsx +++ b/frontend/src/app/index.tsx @@ -4,23 +4,16 @@ import { Layout } from "@/app/Layout"; import { routerPaths } from "@/app/routerPaths"; import { Login } from "@/pages/Login/Login"; import { Register } from "@/pages/Register/Register"; +import { Overview } from "@/pages/Overview/Overview"; const router = createHashRouter([ { path: routerPaths.LOGIN, - element: ( - - - - ), + element: , }, { path: routerPaths.REGISTER, - element: ( - - - - ), + element: , }, { path: routerPaths.ROOT, @@ -28,7 +21,11 @@ const router = createHashRouter([ children: [ { index: true, - element: Overview, + element: ( + + + + ), }, { path: routerPaths.JOBSEEKERS, diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx new file mode 100644 index 0000000..2f915be --- /dev/null +++ b/frontend/src/auth/AuthContext.tsx @@ -0,0 +1,42 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; +import { getAuth, onAuthStateChanged, type User } from "firebase/auth"; +import { getFirebaseApp } from "@/auth/firebase"; + +export interface AuthContextValue { + user: User | null; + /** True during the initial Firebase auth state resolution — render nothing gated on auth until false. */ + loading: boolean; + /** Returns the current user's Firebase ID token, refreshing it if needed. Throws if not signed in. */ + getIdToken: () => Promise; +} + +export const AuthContext = createContext(null); + +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider."); + } + return context; +} + +export function AuthProvider({ children }: Readonly<{ children: ReactNode }>) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const auth = getAuth(getFirebaseApp()); + const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => { + setUser(firebaseUser); + setLoading(false); + }); + return unsubscribe; + }, []); + + async function getIdToken(): Promise { + if (!user) throw new Error("Not signed in"); + return user.getIdToken(); + } + + return {children}; +} diff --git a/frontend/src/auth/firebase.ts b/frontend/src/auth/firebase.ts new file mode 100644 index 0000000..a487c05 --- /dev/null +++ b/frontend/src/auth/firebase.ts @@ -0,0 +1,25 @@ +import { initializeApp, getApps } from "firebase/app"; +import type { FirebaseApp } from "firebase/app"; + +export interface FirebaseConfig { + apiKey: string; + authDomain: string; + projectId: string; +} + +function getFirebaseConfig(): FirebaseConfig { + return { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY ?? "", + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN ?? "", + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID ?? "", + }; +} + +let _app: FirebaseApp | null = null; + +export function getFirebaseApp(): FirebaseApp { + if (_app) return _app; + const existing = getApps(); + _app = existing.length > 0 ? existing[0] : initializeApp(getFirebaseConfig()); + return _app; +} diff --git a/frontend/src/auth/services/Authentication.service.ts b/frontend/src/auth/services/Authentication.service.ts index f769d0d..4a28e2f 100644 --- a/frontend/src/auth/services/Authentication.service.ts +++ b/frontend/src/auth/services/Authentication.service.ts @@ -1,4 +1,14 @@ +import { + getAuth, + signInWithEmailAndPassword, + createUserWithEmailAndPassword, + signInWithPopup, + GoogleAuthProvider, + signOut, + type UserCredential, +} from "firebase/auth"; import type { LoginRequest, RegisterRequest } from "@/auth/auth.types"; +import { getFirebaseApp } from "@/auth/firebase"; export const AUTH_API_BASE = "/api/auth"; @@ -14,6 +24,17 @@ export class AuthApiError extends Error { } } +function mapFirebaseError(error: unknown): AuthApiError { + const code = (error as { code?: string }).code ?? ""; + if (code === "auth/user-not-found" || code === "auth/wrong-password" || code === "auth/invalid-credential") { + return new AuthApiError(401, "invalid_credentials", "Invalid email or password."); + } + if (code === "auth/email-already-in-use") { + return new AuthApiError(409, "email_taken", "An account with this email already exists."); + } + return new AuthApiError(500, "unknown", "An unexpected error occurred."); +} + export class AuthenticationService { private static instance: AuthenticationService | null = null; @@ -22,19 +43,35 @@ export class AuthenticationService { return AuthenticationService.instance; } - async login(_request: LoginRequest): Promise { - // TODO: call the auth API. + private get _auth() { + return getAuth(getFirebaseApp()); + } + + async login(request: LoginRequest): Promise { + try { + return await signInWithEmailAndPassword(this._auth, request.email, request.password); + } catch (error) { + throw mapFirebaseError(error); + } } - async register(_request: RegisterRequest): Promise { - // TODO: call the auth API. + async register(request: RegisterRequest): Promise { + try { + return await createUserWithEmailAndPassword(this._auth, request.email, request.password); + } catch (error) { + throw mapFirebaseError(error); + } } - async loginWithGoogle(): Promise { - // TODO: call the auth API. + async loginWithGoogle(): Promise { + try { + return await signInWithPopup(this._auth, new GoogleAuthProvider()); + } catch (error) { + throw mapFirebaseError(error); + } } - logout(): void { - // TODO: clear the session. + logout(): Promise { + return signOut(this._auth); } } diff --git a/frontend/src/i18n/locales/en-GB/translation.json b/frontend/src/i18n/locales/en-GB/translation.json index 17a3baa..98ec8b1 100644 --- a/frontend/src/i18n/locales/en-GB/translation.json +++ b/frontend/src/i18n/locales/en-GB/translation.json @@ -93,6 +93,19 @@ "accountSettings": "Account settings" } }, + "overview": { + "title": "Overview", + "reach": { + "error": "Failed to load reach data.", + "cards": { + "totalUsers": "Total Users", + "activeUsers30d": "Active (30d)", + "totalLogins": "Total Logins", + "avgLoginsPerUser": "Avg Logins / User", + "avgSessionMinutes": "Avg Session (min)" + } + } + }, "filters": { "activeLabel": "Active filters", "clearAll": "Clear all", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index efd538a..817a101 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -25,10 +25,14 @@ async function boot() { await applyBranding(); await initI18n(); + const { AuthProvider } = await import("./auth/AuthContext"); + createRoot(document.getElementById("root")!).render( }> - + + + ); diff --git a/frontend/src/mocks/browser.ts b/frontend/src/mocks/browser.ts index bcd82e4..c1cfdbf 100644 --- a/frontend/src/mocks/browser.ts +++ b/frontend/src/mocks/browser.ts @@ -1,4 +1,6 @@ import { setupWorker } from "msw/browser"; -import { handlers } from "./handlers"; +import { brandingHandler } from "./handlers"; -export const worker = setupWorker(...handlers); +// Only mock branding in the dev browser worker — all API calls go through +// the Vite proxy to the real backend running on localhost:8080. +export const worker = setupWorker(brandingHandler); diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts index 619f081..9e48766 100644 --- a/frontend/src/mocks/handlers.ts +++ b/frontend/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { http, HttpResponse, type HttpHandler } from "msw"; import type { BrandingConfig } from "@/branding/brandingConfig"; +import type { ReachResponse } from "@/analytics/analytics.types"; /** * Mirrors public/branding.json. Kept as a plain object (not imported from @@ -30,9 +31,29 @@ const defaultBranding: BrandingConfig = { }, }; +const stubReach: ReachResponse = { + summary: { + total_users: 12_450, + active_users_30d: 3_210, + total_logins: 48_900, + avg_logins_per_user: 3.93, + avg_session_minutes: 22, + }, + series: [ + { label: "Jan", cumulative: 8_000, added: 900, new_users: 900, returning: 7_100, logins: 6_200 }, + { label: "Feb", cumulative: 9_200, added: 1_200, new_users: 1_200, returning: 8_000, logins: 7_100 }, + { label: "Mar", cumulative: 10_400, added: 1_100, new_users: 1_100, returning: 9_300, logins: 8_400 }, + { label: "Apr", cumulative: 11_100, added: 700, new_users: 700, returning: 10_400, logins: 9_100 }, + { label: "May", cumulative: 11_800, added: 700, new_users: 700, returning: 11_100, logins: 9_800 }, + { label: "Jun", cumulative: 12_450, added: 650, new_users: 650, returning: 11_800, logins: 10_400 }, + ], +}; + +/** Exported individually so the browser dev worker can include only this one. */ +export const brandingHandler = http.get("/branding.json", () => HttpResponse.json(defaultBranding)); + /** - * Shared MSW request handlers, consumed by both the browser worker - * (src/mocks/browser.ts, for `yarn dev`) and Storybook - * (.storybook/preview.tsx), so app and stories mock the same API. + * Full handler list for tests and Storybook — includes all API stubs so + * components render with realistic data without needing the backend running. */ -export const handlers: HttpHandler[] = [http.get("/branding.json", () => HttpResponse.json(defaultBranding))]; +export const handlers: HttpHandler[] = [brandingHandler, http.get("/api/reach", () => HttpResponse.json(stubReach))]; diff --git a/frontend/src/pages/Overview/Overview.stories.tsx b/frontend/src/pages/Overview/Overview.stories.tsx new file mode 100644 index 0000000..a162a66 --- /dev/null +++ b/frontend/src/pages/Overview/Overview.stories.tsx @@ -0,0 +1,72 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse, delay } from "msw"; +import type { ReactNode } from "react"; +import { AuthContext } from "@/auth/AuthContext"; +import { FiltersProvider } from "@/filters/FiltersContext"; +import type { ReachResponse } from "@/analytics/analytics.types"; +import { Overview } from "./Overview"; + +const stubReach: ReachResponse = { + summary: { + total_users: 12_450, + active_users_30d: 3_210, + total_logins: 48_900, + avg_logins_per_user: 3.93, + avg_session_minutes: 22, + }, + series: [], +}; + +/** + * Overview reads auth (for the bearer token) and filters (for the query params). + * Storybook's preview provides router + i18n + MSW, but not these two — so the + * story supplies a signed-in AuthContext stub and a FiltersProvider itself. + */ +function OverviewHarness({ children }: Readonly<{ children: ReactNode }>) { + return ( + "storybook-token" }}> + {children} + + ); +} + +const meta = { + component: Overview, + tags: ["autodocs"], + parameters: { layout: "fullscreen" }, + decorators: [ + (Story) => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Success: Story = { + parameters: { + msw: { handlers: [http.get("/api/reach", () => HttpResponse.json(stubReach))] }, + }, +}; + +export const Loading: Story = { + parameters: { + msw: { + handlers: [ + http.get("/api/reach", async () => { + await delay("infinite"); + return HttpResponse.json(stubReach); + }), + ], + }, + }, +}; + +export const Error: Story = { + parameters: { + msw: { handlers: [http.get("/api/reach", () => HttpResponse.error())] }, + }, +}; diff --git a/frontend/src/pages/Overview/Overview.test.tsx b/frontend/src/pages/Overview/Overview.test.tsx new file mode 100644 index 0000000..6b34f7f --- /dev/null +++ b/frontend/src/pages/Overview/Overview.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, waitFor } from "@/_test_utilities/test-utils"; +import { http, HttpResponse } from "msw"; +import { server } from "@/mocks/server"; +import { FiltersProvider } from "@/filters/FiltersContext"; +import { Overview, DATA_TEST_ID } from "@/pages/Overview/Overview"; +import type { ReachResponse } from "@/analytics/analytics.types"; +import { describe, it, expect } from "vitest"; + +const givenReach: ReachResponse = { + summary: { + total_users: 5_000, + active_users_30d: 1_200, + total_logins: 20_000, + avg_logins_per_user: 4.0, + avg_session_minutes: 18, + }, + series: [], +}; + +function renderOverview() { + return render( + + + + ); +} + +describe("Overview", () => { + describe("Reach summary", () => { + it("should show the loading state while the reach data is being fetched", () => { + // GIVEN the reach endpoint has not yet responded + server.use(http.get("/api/reach", async () => new Promise(() => {}))); + + // WHEN the overview is rendered + renderOverview(); + + // THEN the loading indicator is visible + expect(screen.getByTestId(DATA_TEST_ID.REACH_LOADING)).toBeInTheDocument(); + }); + + it("should display the reach summary cards after a successful fetch", async () => { + // GIVEN the reach endpoint returns stub data + server.use(http.get("/api/reach", () => HttpResponse.json(givenReach))); + + // WHEN the overview is rendered + renderOverview(); + + // THEN the reach summary cards are eventually shown + await waitFor(() => expect(screen.getByTestId(DATA_TEST_ID.REACH_SUMMARY)).toBeInTheDocument()); + // AND the total users are displayed + expect(screen.getByText("5,000")).toBeInTheDocument(); + // AND the active users are displayed + expect(screen.getByText("1,200")).toBeInTheDocument(); + }); + + it("should display an error message when the reach fetch fails", async () => { + // GIVEN the reach endpoint returns a server error + server.use(http.get("/api/reach", () => HttpResponse.error())); + + // WHEN the overview is rendered + renderOverview(); + + // THEN an error message is eventually shown + await waitFor(() => expect(screen.getByTestId(DATA_TEST_ID.REACH_ERROR)).toBeInTheDocument()); + }); + }); +}); diff --git a/frontend/src/pages/Overview/Overview.tsx b/frontend/src/pages/Overview/Overview.tsx new file mode 100644 index 0000000..b592d2c --- /dev/null +++ b/frontend/src/pages/Overview/Overview.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { useReach } from "@/pages/Overview/useReach"; + +const uniqueId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; + +export const DATA_TEST_ID = { + CONTAINER: `overview-container-${uniqueId}`, + REACH_SUMMARY: `overview-reach-summary-${uniqueId}`, + REACH_ERROR: `overview-reach-error-${uniqueId}`, + REACH_LOADING: `overview-reach-loading-${uniqueId}`, +}; + +function ReachSummaryCard({ label, value }: Readonly<{ label: string; value: string | number }>) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +export function Overview() { + const { t } = useTranslation(); + const reach = useReach(); + + return ( +
+

{t("overview.title")}

+ + {reach.status === "loading" && ( +

+ {t("common.loading")} +

+ )} + + {reach.status === "error" && ( +

+ {t("overview.reach.error")} +

+ )} + + {reach.status === "success" && ( +
+ + + + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/Overview/useReach.ts b/frontend/src/pages/Overview/useReach.ts new file mode 100644 index 0000000..d7ec8a8 --- /dev/null +++ b/frontend/src/pages/Overview/useReach.ts @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import { useAuth } from "@/auth/AuthContext"; +import { useFilters } from "@/filters/FiltersContext"; +import { AnalyticsService } from "@/analytics/Analytics.service"; +import type { ReachResponse } from "@/analytics/analytics.types"; + +export type ReachState = + { status: "loading" } | { status: "error"; message: string } | { status: "success"; data: ReachResponse }; + +export function useReach(): ReachState { + const { getIdToken } = useAuth(); + const { filters } = useFilters(); + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + + (async () => { + try { + const token = await getIdToken(); + const data = await AnalyticsService.getInstance().getReach( + { + start_date: filters.dateRange.start, + end_date: filters.dateRange.end, + granularity: filters.granularity, + audience_segment: filters.audienceSegment ?? undefined, + login_method: filters.loginMethod ?? undefined, + institution_id: filters.institutionDrillDownId ?? undefined, + }, + token + ); + if (!cancelled) setState({ status: "success", data }); + } catch { + if (!cancelled) setState({ status: "error", message: "Failed to load reach data." }); + } + })(); + + return () => { + cancelled = true; + }; + }, [getIdToken, filters]); + + return state; +} diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 343982c..69152c9 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -3,6 +3,32 @@ import { afterAll, afterEach, beforeAll, vi } from "vitest"; import { server } from "@/mocks/server"; import { MockTrans, mockI18nInstance, useTranslationMock } from "@/i18n/i18nMock"; +// Mock Firebase Auth so tests never hit the real Firebase SDK or network. +// AuthContext and AuthenticationService are both covered by unit tests that +// mock firebase/auth directly; this global ensures any component that renders +// under AuthProvider gets a signed-in stub user without needing Firebase config. +vi.mock("firebase/auth", () => { + const mockUser = { uid: "test-uid", email: "test@example.com", getIdToken: async () => "test-id-token" }; + class MockGoogleAuthProvider {} + return { + getAuth: vi.fn(() => ({})), + onAuthStateChanged: vi.fn((_auth: unknown, callback: (user: unknown) => void) => { + callback(mockUser); + return () => {}; + }), + signInWithEmailAndPassword: vi.fn(async () => ({ user: mockUser })), + createUserWithEmailAndPassword: vi.fn(async () => ({ user: mockUser })), + signInWithPopup: vi.fn(async () => ({ user: mockUser })), + GoogleAuthProvider: MockGoogleAuthProvider, + signOut: vi.fn(async () => {}), + }; +}); + +vi.mock("firebase/app", () => ({ + initializeApp: vi.fn(() => ({})), + getApps: vi.fn(() => [{}]), +})); + beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ae33dfd..42dce55 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,6 +13,11 @@ const dirname = typeof __dirname !== "undefined" ? __dirname : path.dirname(file // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + proxy: { + "/api": "http://localhost:8080", + }, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1be9156..316bb55 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -437,6 +437,400 @@ resolved "https://registry.yarnpkg.com/@exodus/bytes/-/bytes-1.15.1.tgz#b13bc464ca162c17abf0837fb3a11aeab79e45d1" integrity sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q== +"@firebase/ai@2.14.0": + version "2.14.0" + resolved "https://registry.yarnpkg.com/@firebase/ai/-/ai-2.14.0.tgz#292789b4dfa817ac17c25fef36bc7b7ffd710c31" + integrity sha512-TYEQqCQUTyVHuG/HVi9vau6F9kvEaS49o/hmdn/yUuN6ZXQkwIml2nNJTIBfjNl/r9LOxwUNILgcOY16nxObug== + dependencies: + "@firebase/app-check-interop-types" "0.3.4" + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/analytics-compat@0.2.29": + version "0.2.29" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.29.tgz#9a13c73db2c6ad63a854a5ce3a7b16ff74cd7630" + integrity sha512-allztvCvCUlItZzD97TiRAtGoFJzR1FQFmLxbaLc6PvgscqD9cl5NdKPTtka6keShVYXvCZJpzWcRoH4TME8rw== + dependencies: + "@firebase/analytics" "0.10.23" + "@firebase/analytics-types" "0.8.4" + "@firebase/component" "0.7.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/analytics-types@0.8.4": + version "0.8.4" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.4.tgz#194ee289e7293e47b1bd6221147f0dcc02f92e3d" + integrity sha512-zQ+XTgkwH6CY/eUSHJRP7e4LxM30RCxlCmob5sy2axs25GE3Ny0XdgpDscMTHHQIGqWkxPXad4w2Mw9sCgT8zQ== + +"@firebase/analytics@0.10.23": + version "0.10.23" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.23.tgz#ec92235e1b35ef0f22b286194e458f5640570b00" + integrity sha512-34ALWXzWA6PTRUA5hipZmsm1RKzeecw5J1+qTCXsiMzwLqONC+GuTIQSdmm91MmTAEA+wG1Q5t0IFahcYQOqAA== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/installations" "0.6.23" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/app-check-compat@0.4.6": + version "0.4.6" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.4.6.tgz#78af8bcd3cbbbf857541926d0dc63f899b22f037" + integrity sha512-2pzNEZEkX84jSqy6TH6FI1HSLA1lc7kakRUybBbKjg9YhIttPlW/XX3N9CDtChji2PTTPWVPZiWhB10exHfA+A== + dependencies: + "@firebase/app-check" "0.13.0" + "@firebase/app-check-types" "0.5.4" + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/app-check-interop-types@0.3.4": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.4.tgz#747bf9fe8e9db11f6a44b695f20754f2a20208a4" + integrity sha512-zz3i6e13B8BfWiLy8MABtTh8aGIACgKbf9UVnyHcWs+yQzJXgQcl8A46b0zfaiJHdQ+niF0ouAfcpuf+3LMPQg== + +"@firebase/app-check-types@0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.4.tgz#8001732e81332dd77d898d82fe773761cf2f023b" + integrity sha512-xV7JsIyzVr15aA7f3Pi0rB9gdBuVubs89FGA8VkRYA4g0l78poADgdfrScgf7NndSg9mm7cR7PJyY0+t22KaGw== + +"@firebase/app-check@0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.13.0.tgz#de1d1e4671172b55eda2ad6b0a6182d763926b73" + integrity sha512-AbMttBKazQvGVXBZhQdVAdPzRhwHyJAY3Ghu5y2C7IZKIDIppzNYz0shTZ1mP4FBJa+28BuC4t+5h1Q6pT3Asg== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/app-compat@0.5.16": + version "0.5.16" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.5.16.tgz#2c2f87fb29f298dd220b66b7b28493ff9f2e7ff7" + integrity sha512-shQq37O8qELDzvsVwYPlDXwD1zlcrZ0m2bpBF5ov2HSbY8x+AHsnL5TtJ2e1JAfkQN05qHao1AfabS69PN6GiA== + dependencies: + "@firebase/app" "0.16.0" + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/app-types@0.9.5": + version "0.9.5" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.5.tgz#4c59391fd2559530d709a614d49f62af4ad8766b" + integrity sha512-YevqTjvo7Iujsa9Dwowmd6dSoElhzmD63ZSrq6bzjvQ6POjYgNjOFHLmNIgJs48eNO093NCERibuFnxbfOvU7A== + dependencies: + "@firebase/logger" "0.5.1" + +"@firebase/app@0.16.0": + version "0.16.0" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.16.0.tgz#9521de87efb74eed879f201be1abc8df92364b37" + integrity sha512-G+ZGEyVP8YTb3ay6A+XpcYgFH3sTESHcnHU/EyTktodqhz2BHkLq+QEP7IVwjiMX0cxYwpVKip0/wC0KZcn9vQ== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/auth-compat@0.6.9": + version "0.6.9" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.6.9.tgz#16db5b6f14f4a09c861aa643310e421db488e528" + integrity sha512-/hHeTBmQ61+N5J1RECls+WfskZTY78JXr7aO5EMOfUpqJvDqvoS+568k0rp6Ss/4UWwBjadILs+H+SGy1zCS3A== + dependencies: + "@firebase/auth" "1.13.4" + "@firebase/auth-types" "0.13.1" + "@firebase/component" "0.7.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/auth-interop-types@0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.5.tgz#828e2e160cff40fa78ecb4b6e3187c88158eb2e9" + integrity sha512-1Li/YuBDBAXcKv7BzY4U28gontUmAaw53sYiqbaVOMCFb2lFKK/c3CGMUWqtwe7+TXrl3poWnTCL5umYBg85Eg== + +"@firebase/auth-types@0.13.1": + version "0.13.1" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.13.1.tgz#eb5abafdaa40dd3b6badfab111dd94f7e196ea66" + integrity sha512-0c1Mnid0uMDfGJHeUS4zfvBa4/CedJXotGy/n/NZJnBjwiJawt0ZYU+wH2VAVLiRCEfG2ncCkAX3yd1/2nrB7g== + +"@firebase/auth@1.13.4": + version "1.13.4" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-1.13.4.tgz#440862edc7782cbd28ce6606cb31249e009efff9" + integrity sha512-s+NS1aV0DDyyfoIMeSz53HXnVTv7ufJjJfrP63XyaWHweJ5vOoxKWrTm5tO7S7PDqvyOa/Wi3oP0dgAo6JTMMA== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/component@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.7.4.tgz#1c7942806ba03334e5ef432e088a07efad787cf3" + integrity sha512-tLpOaaCol9ugUIYp2R3CbWPPA8Ajg/papX/XHEy8U52b/QXH3BbX8tTJX9aShDCjp+9sMAxMLD94i7lresdugQ== + dependencies: + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/data-connect@0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@firebase/data-connect/-/data-connect-0.7.2.tgz#1333edc262b30a4104d892840d471a091e392e83" + integrity sha512-Z64TRTp5KsvZtuCS1BhEg0H63TTDIi6k7idGG+z1ImAnP2qHv+xt0S5rzAONpiO7Z1geldWhpu1iY/ju+l3a3w== + dependencies: + "@firebase/auth-interop-types" "0.2.5" + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/database-compat@2.1.5": + version "2.1.5" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-2.1.5.tgz#59d29bfb62aa6e71ea1cd7de45b2cf77648210ce" + integrity sha512-m2KZDNXrg8DBzXWQNbbrjOhsJnM+ctsSFaDYKrqj1gEetQ8BSAwRuMUdeWLM9a6qPBgOvOA+o09j1BSEzdFqOg== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/database" "1.1.4" + "@firebase/database-types" "1.0.21" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/database-types@1.0.21": + version "1.0.21" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-1.0.21.tgz#a571c6491bb7d2e0b71b8eac0511acf7f87d5a24" + integrity sha512-SX1jUqhttKgg/m9dYRTvqU9QvucBooziWfA986r4cpsbi4zlsvewe424j3Vpduwd6DG1MSAMfBVT2VqA61FnkA== + dependencies: + "@firebase/app-types" "0.9.5" + "@firebase/util" "1.15.2" + +"@firebase/database@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-1.1.4.tgz#af9ada837b44e5b65b93d185ce950a1501877172" + integrity sha512-D+j4+8uhGtNd1tVD+X+c8JrC4ppStGJKyujSQt2NPwdN26QcCk0BeIxue+UqspHkHiFHyQOimwlzjLewGq6S+A== + dependencies: + "@firebase/app-check-interop-types" "0.3.4" + "@firebase/auth-interop-types" "0.2.5" + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.4.12": + version "0.4.12" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.4.12.tgz#bfe962327a06ddb7df2d7262ebb045d6ed976ca3" + integrity sha512-k2uX81Ao/S0jnFcWGPOQpKK1cPlJHvD9WIqh/RE1XBDP2yg5zhE4rHhSg1rtB11k39q3nKon9XLNDDrPjGclag== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/firestore" "4.17.0" + "@firebase/firestore-types" "3.0.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/firestore-types@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.4.tgz#a7205638195de1d356fbd39a620fd2d93c11d792" + integrity sha512-jGn+JSS4X9zZsrfu7Yw66v5YRdOLD1oyQh4USR0xWl4CUqV/DA6bNIXRPpxH/cUl3iVTNiP6MN7g+EL42A4qfA== + +"@firebase/firestore@4.17.0": + version "4.17.0" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.17.0.tgz#5bc6c8ed1bbf9c30c91bb3df2ae143faede78d15" + integrity sha512-P9tof6pyO1bnLlMWbux+5O7WFJqlb7OTPMKxxOiXKYiQl7mxykAvxr1BFCgWeEXUU7DZxQncyJ040B0IhFVZCg== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + "@firebase/webchannel-wrapper" "1.0.6" + "@grpc/grpc-js" "~1.9.0" + "@grpc/proto-loader" "^0.7.8" + re2js "^2.8.3" + tslib "^2.1.0" + +"@firebase/functions-compat@0.4.6": + version "0.4.6" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.4.6.tgz#97a2c833d4a772694fa4a2c4e7358443bd98f4c2" + integrity sha512-dj9sOet+FIU91jeU4A3vGJoXHty7NqkSfjRLCwLgJXPDk1m72KFuxD3nlFgw/yXx/Fr7UjqzbxZ0LrIOdpx7+w== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/functions" "0.13.6" + "@firebase/functions-types" "0.6.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/functions-types@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.4.tgz#bb73ed93aa396419f907967202572ea55a605a40" + integrity sha512-zV6kgqtduR4rUAdC/ilS7kmb93XD7bEZoJDlVBZqlOw2uGGGCNBQBuleww2rr0Ulr3L9o2TDjumEt68/l1f9DQ== + +"@firebase/functions@0.13.6": + version "0.13.6" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.13.6.tgz#72d19d9b7d5a4136505333cd731a5f5aa8bbb59f" + integrity sha512-9obLnzeQUivK5lmtGFOU2ucQ38BjTp+jpPtbfFp/mDsdVCvEpRqdWNvMMQ6aQwR4vcVc/utsvngm5BRkXbc7ZA== + dependencies: + "@firebase/app-check-interop-types" "0.3.4" + "@firebase/auth-interop-types" "0.2.5" + "@firebase/component" "0.7.4" + "@firebase/messaging-interop-types" "0.2.5" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/installations-compat@0.2.23": + version "0.2.23" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.23.tgz#4eeeb1c14c1bd193c802a1b0d161d1dfbf76ff5f" + integrity sha512-isaXmjb9roM83eVeXAe+ZRNKYNsSo2s0aNM+cy04AAGEyVL/d8Aa11GwEXovRFeYjl9+1yRAOxRDTOukZRwTxA== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/installations" "0.6.23" + "@firebase/installations-types" "0.5.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/installations-types@0.5.4": + version "0.5.4" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.4.tgz#3b1e41ae7d1b89eb3b4a1c9cc583a12bed83aa27" + integrity sha512-U2eFapdHwjb43Vx9o+Pmj4dFfvcHEK1IirEFLqMtWrTHvmdrS3gBpBD1kmJk/9HjsOtoHZxJ2Paoe79e+L1ZPg== + +"@firebase/installations@0.6.23": + version "0.6.23" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.23.tgz#687c03a51e8d30936d7673f879c388e8ac3593c8" + integrity sha512-MBkbcQfd+3qHjW+slsH4s7jH5qTdGlYpwqmxEZ7QcIpgDxu1SKyU0f+mCZhCt1BCacLNiOWF5L0R06N0LtlfMg== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/util" "1.15.2" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/logger@0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.5.1.tgz#da1eb266b3fa8d1375617cb64c36c9a5cb63d8e1" + integrity sha512-vZKLsqE1ABOy8OjQiE7cUTFn4gvaqlk88yp8N94Pk/sDpq61YqZGqmVFZTvOyflTwuYFcWirBdYGoJgbDaXKYQ== + dependencies: + tslib "^2.1.0" + +"@firebase/messaging-compat@0.2.28": + version "0.2.28" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.28.tgz#f623183ca7ad1dddc50a1d4ab623b2bf937974bb" + integrity sha512-/AmMqHRnSQhPsdeED3ocs+s30/tpFvZDiiwIYY2uXFRvLujo1fnbPOeCFoe4Y+dRy1LCSjpvJf+dy5ZTsxi1yg== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/messaging" "0.13.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/messaging-interop-types@0.2.5": + version "0.2.5" + resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.5.tgz#078657f9be789bfaa31156a969630ecc4c0d5e16" + integrity sha512-tUEKnaAP2Y/MNIqgnriPpV6e5l13Vs/+p2yrd6NGlncPJT9O3a8muYZtdnWe+IJ4fgKLHJVC79n/asxk/N5Msw== + +"@firebase/messaging@0.13.1": + version "0.13.1" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.13.1.tgz#32beaa0d6967067ea589fc72f18a6156321ec5d9" + integrity sha512-kL8fdjbNBI7hprlXJrUjktDWosrpT4JtfwXtVVevImPF/rBRAsC+LS/jIs+kgQVuotnvMhaBCgAFipBoY9YU9g== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/installations" "0.6.23" + "@firebase/messaging-interop-types" "0.2.5" + "@firebase/util" "1.15.2" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/performance-compat@0.2.26": + version "0.2.26" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.26.tgz#559a397ee5d0d0d50268d257198c8d2094cd5906" + integrity sha512-jgoocXLN6ao26xWQ8pzosmzQ33uLzGBJQPNK0NTbVy1XvIHr5pfgBf9hWLOxsWe+R7sJq5bjD+8ybXprmt61mA== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/performance" "0.7.13" + "@firebase/performance-types" "0.2.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/performance-types@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.4.tgz#9aa9e531f974f4b5e5a09bffff12406e0dc11e1f" + integrity sha512-kJSEk7b0uhpcPRyL4SQ/GPujLqk52XNKcXlnsKDbWGAb9vugcLvOU3u6zfEdwd+d8hWJb5S5ZizV1JFFI0nkKg== + +"@firebase/performance@0.7.13": + version "0.7.13" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.7.13.tgz#59cd61dd9edcb61b241e353118acfabd007a7030" + integrity sha512-1u6fuXP9cj0s+lkTFAspr/ttfPebPbEdpx+5Wdr4mPZbp8qH2KCMxOddEAR1ZMRa5GI0E7hDYSnolEmbqOFOAg== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/installations" "0.6.23" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + web-vitals "^4.2.4" + +"@firebase/remote-config-compat@0.2.28": + version "0.2.28" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.28.tgz#4d26f630b2462b09ee1bf0c57f2d254bbd58da8d" + integrity sha512-kEO9Gn6fbmVj7eNUtZ6d59mLgUDUD0qo7aCicGOWNfuRWTaUv3CF9DMYychO61zaEQ3cfA+CEny4V1E8A1gRGA== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/logger" "0.5.1" + "@firebase/remote-config" "0.9.1" + "@firebase/remote-config-types" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/remote-config-types@0.5.1": + version "0.5.1" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.5.1.tgz#2bc52831f8b52aff2b1b434158b4f7803e05f86e" + integrity sha512-cX/1LT6KQwkXzck2eSzeKnuvXZCyr8qaPpDcikoJs7jmI+oBOXixpDLeDtWj1U6GNMkIoXrEDNoyT2Ypcyp5/A== + +"@firebase/remote-config@0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.9.1.tgz#62cf8c305b8cd94f07fda5c8d531204b7175dab8" + integrity sha512-nzQUSJnk1zAZEl2Q5O3I7Z61cYLK5JI4H6wyyOiHkVZ+bmgy1YXNNMptNbVjixMQ/eCzgA6nZRaC+1eBcJGUFA== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/installations" "0.6.23" + "@firebase/logger" "0.5.1" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/storage-compat@0.4.4": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.4.4.tgz#7ddc6769047fbc276cbf08ddd1fc24f533c391a1" + integrity sha512-qSRgCB9f2R/nCp8t/8OC101cIFBFeUazlRInOMdzbnLzvrQBzEfx19SrR4pvdj/0+M+P/y8AK/a2s+3EB+B1Pw== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/storage" "0.14.4" + "@firebase/storage-types" "0.8.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/storage-types@0.8.4": + version "0.8.4" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.4.tgz#88d72c80eb9a1167da33e8c551fd3b703c2422ec" + integrity sha512-BT7cwxJOx8SWwlQfrlC+bD/Sk3Cw+1odCi8UZNFNWTVZoPsBnA5W+mqtZzVnvsdJpXCFGSGQ7R7vOR6dtM/BRA== + +"@firebase/storage@0.14.4": + version "0.14.4" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.14.4.tgz#110afe6d6256e2b339d690e36578abcc12ccc8ce" + integrity sha512-jfzEWZb3Fpsq3FwAB2ifoc8mcSh935qXdDou3TpyjDWa45hhNcZUv8/w28/10njByhfK7snbakKN30nwnzQ3/w== + dependencies: + "@firebase/component" "0.7.4" + "@firebase/util" "1.15.2" + tslib "^2.1.0" + +"@firebase/util@1.15.2": + version "1.15.2" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.15.2.tgz#0650ace6fa8b98c772910e9fe50e20e61f4093f4" + integrity sha512-974pWIZVLDMc5GW5YAsj8y0XxULxIy/sPUy7tsxmWbF93KRIyh9xpuHlh0zDL+shUcf5nHDjFOg9YLiQ763eiA== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.6.tgz#4be6a62b8c27a6bad8cc8da41f9a12de901e965d" + integrity sha512-Vr/Mqu79dMwGRAyGbJ4uN4+BtXB3/mRTdzetD1daWNeG8QaWuzhhbG77GltO5c0yYmYls8i250iX73624GJd7Q== + "@floating-ui/core@^1.8.0": version "1.8.0" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.8.0.tgz#d01c0bbea02e4a57f6fd7d5de6fc2c5c7dca40e1" @@ -464,6 +858,24 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.12.tgz#afefe785949f16ac4cdd1e695935a321572dd56a" integrity sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww== +"@grpc/grpc-js@~1.9.0": + version "1.9.16" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.16.tgz#614f85036ac8e3c957374c1bd1ebb05934a79a1c" + integrity sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ== + dependencies: + "@grpc/proto-loader" "^0.7.8" + "@types/node" ">=12.12.47" + +"@grpc/proto-loader@^0.7.8": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + "@inquirer/ansi@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@inquirer/ansi/-/ansi-2.0.7.tgz#86de22810cac3ed406ec10f8d66016815b8226b4" @@ -909,6 +1321,53 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww== +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + +"@protobufjs/eventemitter@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz#d512cb26c0ae026091ee2c1167f1be6faf5c842a" + integrity sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg== + +"@protobufjs/fetch@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.1.tgz#4d6fc00c8fb64016a5c81b469d549046350f1065" + integrity sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.2.tgz#78d476333d85d5b1c792e257bca74ba080da49a4" + integrity sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug== + "@radix-ui/number@1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.2.tgz#3ace52303a4a570d03dc79bf17d6da49ed40d0cf" @@ -2100,6 +2559,13 @@ dependencies: undici-types "~8.3.0" +"@types/node@>=12.12.47", "@types/node@>=13.7.0": + version "26.1.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.1.2.tgz#da79708f1f9c6294f4cdec8f455a3032b028808a" + integrity sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg== + dependencies: + undici-types "~8.3.0" + "@types/react-dom@^19.2.3": version "19.2.3" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" @@ -2693,11 +3159,52 @@ fast-wrap-ansi@^0.2.0: dependencies: fast-string-width "^3.0.2" +faye-websocket@0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +firebase@^12.17.0: + version "12.17.0" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-12.17.0.tgz#44c43984a50ce6ace7fcd9b8c67ec1f2d23a9aad" + integrity sha512-8iENFg2/k7asnybijN3ZrmTag0BuyiihUXrRZkX+yCW+YocknpzPZ4DUkdbyA/rdrdaCssBqHH8zpdDliv+4ng== + dependencies: + "@firebase/ai" "2.14.0" + "@firebase/analytics" "0.10.23" + "@firebase/analytics-compat" "0.2.29" + "@firebase/app" "0.16.0" + "@firebase/app-check" "0.13.0" + "@firebase/app-check-compat" "0.4.6" + "@firebase/app-compat" "0.5.16" + "@firebase/app-types" "0.9.5" + "@firebase/auth" "1.13.4" + "@firebase/auth-compat" "0.6.9" + "@firebase/data-connect" "0.7.2" + "@firebase/database" "1.1.4" + "@firebase/database-compat" "2.1.5" + "@firebase/firestore" "4.17.0" + "@firebase/firestore-compat" "0.4.12" + "@firebase/functions" "0.13.6" + "@firebase/functions-compat" "0.4.6" + "@firebase/installations" "0.6.23" + "@firebase/installations-compat" "0.2.23" + "@firebase/messaging" "0.13.1" + "@firebase/messaging-compat" "0.2.28" + "@firebase/performance" "0.7.13" + "@firebase/performance-compat" "0.2.26" + "@firebase/remote-config" "0.9.1" + "@firebase/remote-config-compat" "0.2.28" + "@firebase/storage" "0.14.4" + "@firebase/storage-compat" "0.4.4" + "@firebase/util" "1.15.2" + fsevents@2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" @@ -2786,6 +3293,11 @@ html-parse-stringify@^3.0.1: dependencies: void-elements "3.1.0" +http-parser-js@>=0.5.1: + version "0.5.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" + integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== + i18next-browser-languagedetector@^8.2.1: version "8.2.1" resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz#f17a918d376a97aa12a5b63fd8ea559a6231935b" @@ -2798,6 +3310,11 @@ i18next@^26.3.6: resolved "https://registry.yarnpkg.com/i18next/-/i18next-26.3.6.tgz#599db275c50b66d28a69d8f6c5162c245ce9296b" integrity sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA== +idb@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" + integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== + indent-string@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" @@ -3085,6 +3602,16 @@ lightningcss@^1.32.0: lightningcss-win32-arm64-msvc "1.33.0" lightningcss-win32-x64-msvc "1.33.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + +long@^5.0.0, long@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + loupe@^3.1.0, loupe@^3.1.4: version "3.2.1" resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" @@ -3408,6 +3935,23 @@ pretty-format@^27.0.2: ansi-styles "^5.0.0" react-is "^17.0.1" +protobufjs@^7.2.5: + version "7.6.5" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.6.5.tgz#7b9250cdaf4a06139a9f0fe468a40d7d4febca71" + integrity sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.1" + "@protobufjs/fetch" "^1.1.1" + "@protobufjs/float" "^1.0.2" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.3.2" + punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -3474,6 +4018,11 @@ radix-ui@^1.6.4: "@radix-ui/react-use-size" "1.1.2" "@radix-ui/react-visually-hidden" "1.2.8" +re2js@^2.8.3: + version "2.8.6" + resolved "https://registry.yarnpkg.com/re2js/-/re2js-2.8.6.tgz#acbd17a1ad0e98c1f2ee0a2adc17ba0903d9ca95" + integrity sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg== + react-docgen-typescript@^2.2.2: version "2.4.0" resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz#033428b4a6a639d050ac8baf2a5195c596521713" @@ -3641,6 +4190,11 @@ run-applescript@^7.0.0: resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.1.0.tgz#2e9e54c4664ec3106c5b5630e249d3d6595c4911" integrity sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q== +safe-buffer@>=5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + saxes@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" @@ -4067,6 +4621,11 @@ w3c-xmlserializer@^5.0.0: dependencies: xml-name-validator "^5.0.0" +web-vitals@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" + integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw== + webidl-conversions@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz#0657e571fe6f06fcb15ca50ed1fdbcb495cd1686" @@ -4077,6 +4636,20 @@ webpack-virtual-modules@^0.6.2: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8" integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ== +websocket-driver@>=0.5.1: + version "0.7.5" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.5.tgz#569d22764ab21f2de20af0e74b411e8ae5a0fa46" + integrity sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + whatwg-mimetype@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz#d8232895dbd527ceaee74efd4162008fb8a8cf48"