diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4a463f91..8edd8204 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -23,7 +23,9 @@ Before you begin, ensure you have the following tools installed on your system: ## 3. Add env variables to repo where we’ll work -You can connect to your new GCP Argolis Account by setting a `backend/.env` file for the backend and a `frontend/src/environments/development.environment.ts` file for the frontend. +You can connect to your new GCP Argolis Account by setting a `backend/.env` file for the backend and a `frontend/src/environments/environment.development.ts` file for the frontend. + +Copy the template from `frontend/src/environments/environment.development.ts.example` to `frontend/src/environments/environment.development.ts` and modify it. > **Important!!!** set `isLocal = True`, in both frontend and backend, this is so that instead of loggin in with Identity Platform, we login with Firebase, and we keep Identity Platform Authorized Javascript origins clean, without the need to whitelist localhost. @@ -62,7 +64,7 @@ ADMIN_USER_EMAIL="your-user-email" > - **Safe Experimentation**: Clear volume bindings locally without risking production states or accidental cloud data drops. > - **Instant Migrations Validation**: Speed runs Alembic updates completely isolated and offline. -### `frontend/src/environments/development.environment.ts` file +### `frontend/src/environments/environment.development.ts` file ```typescript export const environment = { diff --git a/GEMINI.md b/GEMINI.md index 07eb8f15..a42a7ac7 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -139,6 +139,6 @@ To maintain a pristine codebase, AI Agents **MUST** follow these rules: 10. **Seniority**: Act as a Senior Software Engineer with 10+ years of experience in software development. 11. **Code Review**: Always review the code before finishing execution, if it fails, fix it and run the pre-commit hook again. 12. **Security**: Always review the code for security vulnerabilities and fix them if found. -13. **Isolation**: Always work in isolation, do not modify files outside the scope of the task. Work with docker containers, do not run any gcloud commands locally nor modify any cloud resources. +13. **Isolation**: Always work in isolation, do not modify files outside the scope of the task. Work with docker containers, do not run any gcloud commands locally nor modify any cloud resources without permission. --- diff --git a/README_ENTRA.md b/README_ENTRA.md new file mode 100644 index 00000000..366745a3 --- /dev/null +++ b/README_ENTRA.md @@ -0,0 +1,121 @@ +# Google Cloud Creative Studio: Deployment & Entra ID Setup Guide + +This guide provides step-by-step instructions for cloning, configuring, and deploying the Google Cloud Creative Studio Platform to Google Cloud Platform (GCP) with authentication backed by Microsoft Entra ID (formerly Azure Active Directory) via Workforce Identity Federation and IAP. + +--- + +## 📋 Prerequisites + +Before you begin, ensure you have the following installed and configured: +* **Git**: For cloning the repository. +* **Google Cloud CLI (`gcloud`)**: Authenticated to your GCP account. +* **Terraform**: Version 1.5.0 or later (if running manually, though the bootstrap script handles execution). +* **An active GCP Project** (referred to as `[PROJECT_NAME]` in this guide). +* **Owner permissions** on the GCP Project. +* **Administrator access** to a Microsoft Entra ID Tenant (to create App Registrations). + +--- + +## 🛠️ Step 1: Clone the Repository + +Clone the repository and checkout the latest branch containing the IAP and Entra integration changes. + +1. Open your terminal. +2. Clone the repository: + ```bash + git clone https://github.com/eric-lyons/gcc-creative-studio.git + cd gcc-creative-studio + ``` +3. Switch to the integration branch: + ```bash + git checkout feature/iap-workforce-auth + ``` + +--- + +## 🔑 Step 2: Configure Microsoft Entra ID (Azure AD) + +You need to register the Creative Studio application in your Microsoft Entra Tenant to obtain the client credentials needed for authentication. + +### 1. Create App Registration +1. Sign in to the [Microsoft Entra admin center](https://entra.microsoft.com/) as at least an Application Administrator. +2. Browse to **Identity** > **Applications** > **App registrations** and select **New registration**. +3. Enter a name for the application (e.g., `Creative Studio Dev`). +4. Under **Supported account types**, select **Accounts in this organizational directory only (Single tenant)**. +5. Under **Redirect URI**, select **Single-page application (SPA)** (for MSAL frontend login) and enter the URL where the app will be hosted: + * For local testing: `http://localhost:4200/` + * For deployed app: `https://[YOUR_DOMAIN_OR_IP]/` (or your custom domain). + * *Note: You can add multiple redirect URIs later.* +6. Select **Register**. + +### 2. Collect Client and Tenant IDs +1. After registration, you will be redirected to the App's **Overview** page. +2. Copy the following values and save them: + * **Application (client) ID** + * **Directory (tenant) ID** + +### 3. Generate Client Secret +1. In the left navigation panel, select **Certificates & secrets** > **Client secrets** > **New client secret**. +2. Add a description (e.g., `Creative Studio GCP Deployment`) and select an expiration period. +3. Select **Add**. +4. **CRITICAL**: Copy the **Value** of the secret immediately. It will be hidden permanently once you navigate away from this page. + +### 4. Enable ID Token Issuance (for IAP / WIF) +1. Select **Authentication** in the left panel. +2. Under **Implicit grant and hybrid flows**, ensure **ID tokens (used for implicit and hybrid flows)** is **checked**. +3. Select **Save**. + +--- + +## 🚀 Step 3: Deploy to Google Cloud Platform (GCP) + +The project includes an automated `bootstrap.sh` script that handles the configuration of Terraform variables, GCP Secret Manager initialization, and deployment. + +1. Initialize your Google Cloud application default credentials: + ```bash + gcloud auth application-default login + ``` +2. Make the bootstrap script executable: + ```bash + chmod +x bootstrap.sh + ``` +3. Run the bootstrap script: + ```bash + ./bootstrap.sh + ``` +4. Follow the interactive prompts: + * **GCP Project ID**: Enter `[PROJECT_NAME]`. + * **Environment**: Select `development`. + * **Authentication Choice**: Select **(2) Microsoft Entra ID**. + * **Entra Client ID**: Paste the *Application (client) ID* collected in Step 2. + * **Entra Tenant ID**: Paste the *Directory (tenant) ID* collected in Step 2. + * **Entra Client Secret**: Paste the *Client Secret Value* collected in Step 2. + * **Domain Name**: Enter your domain (e.g., `[YOUR_DOMAIN_OR_IP]` or your custom domain). + * **GCP Organization ID**: Enter your GCP Organization numerical ID (required for Workforce Pools). + +The script will automatically update the `infra/environments/development/development.tfvars` file, create the necessary secrets in GCP Secret Manager, and trigger the Terraform execution to deploy all resources (Cloud Run, Cloud SQL, IAP, GCS, etc.). + +--- + +## 🌐 Step 4: Add a Custom Domain + +To configure a custom domain instead of using the default IP-based hostname: + +### 1. Identify the Load Balancer IP +Once the Terraform deployment finishes successfully, it will output the external IP address of the Global Load Balancer created for IAP. You can also find it in the console: +1. Go to **Network Services** > **Load Balancing** in the GCP Console. +2. Select the load balancer created for your deployment (typically named `cstudio-lb-development` or similar). +3. Locate the Frontend IP address. + +### 2. Configure DNS +1. Log in to your Domain Registrar (e.g., Google Domains, GoDaddy, Cloudflare). +2. Navigate to the DNS management panel for your custom domain. +3. Create an **A Record**: + * **Host/Name**: `@` (for root domain) or `studio` (for a subdomain like `studio.yourdomain.com`). + * **Value/Points to**: The Frontend IP address of the GCP Load Balancer identified above. + * **TTL**: Default (e.g., 3600 seconds). + +### 3. Update the App Configurations +Once DNS propagates, you must update the application to recognize the new domain: +1. Re-run `./bootstrap.sh` and provide your new custom domain (e.g. `studio.yourdomain.com`) when prompted. This updates Terraform and regenerates the SSL certificate for the Load Balancer. +2. Update your Microsoft Entra App Registration (Step 2) to include `https://studio.yourdomain.com/` as an allowed **Redirect URI**. diff --git a/README_IAP.md b/README_IAP.md new file mode 100644 index 00000000..61f841f3 --- /dev/null +++ b/README_IAP.md @@ -0,0 +1,164 @@ +# Securing and Deploying with Identity-Aware Proxy (IAP) & Microsoft Entra ID + +This guide explains how to secure and deploy the **Google Cloud Creative Studio Platform** in a Google Cloud project using **Identity-Aware Proxy (IAP)** federated with **Microsoft Entra ID (Azure AD)** via **Workforce Identity Federation (WIF)**. + +--- + +## Architecture Overview +By securing the application with IAP and Workforce Identity Federation, you completely avoid having to provision or sync user identity accounts into Google Cloud. + +1. An external user attempts to access the HTTPS Load Balancer domain. +2. IAP intercepts the request and checks for authentication. +3. If unauthenticated, IAP redirects the user to Google's Workforce Identity sign-in portal. +4. The sign-in portal redirects the user to your organization's Microsoft Entra ID sign-in page. +5. Upon successful Entra ID authentication, the user is redirected back to Google IAP and authorized based on IAM policy. + +--- + +## Prerequisites +Before beginning the deployment, ensure you have the following: + +### 1. Google Cloud Platform (GCP) +* A **GCP Organization** (Workforce Identity Pools cannot be created in standalone projects). +* Your GCP **Organization ID** (numeric). You can find this by running: + ```bash + gcloud organizations list + ``` +* A target **GCP Project** inside that organization. +* The **Workforce Pool Admin** (`roles/iam.workforcePoolAdmin`) role granted to your GCP user account at the **Organization level**. + +### 2. Microsoft Entra ID (Azure AD) +* An active Microsoft Entra tenant. +* Access to the Azure Portal with permissions to create **App Registrations**. + +--- + +## Step-by-Step Setup Guide + +### Step 1: Microsoft Entra ID App Registration +1. Log in to the [Azure Portal](https://portal.azure.com/). +2. Navigate to **Microsoft Entra ID** (or **Azure Active Directory**). +3. In the left navigation, select **App registrations** > **New registration**. +4. Configure the app registration: + * **Name**: e.g., `GCP Workforce Identity Client` + * **Supported account types**: Select "Accounts in this organizational directory only" (Single Tenant). + * **Redirect URI (optional)**: Select **Web** and add the following global Google sign-in callback URL: + ``` + https://auth.cloud.google/signin-callback/locations/global/workforcePools/cs-workforce-pool/providers/entra-provider + ``` + *(Note: If you change the Workforce Pool ID or Provider ID, update the path accordingly: `/workforcePools//providers/`).* + +5. Click **Register**. +6. On the application Overview page, copy and save these values: + * **Application (client) ID** + * **Directory (tenant) ID** +7. Navigate to **Certificates & secrets** in the left menu. +8. Under the **Client secrets** tab, click **New client secret**. +9. Add a description, choose an expiration period, and click **Add**. +10. Copy and save the secret **Value** (do not copy the Secret ID). *Note: This value is only shown once upon creation, so save it immediately.* + + +--- + +### Step 2: Create a Google Workforce OAuth Client for IAP +Standard Google Accounts OAuth clients cannot be used with Workforce Identity Federation. Instead, you must create a dedicated Workforce Identity OAuth Client using the `gcloud` CLI. + +1. Run the following command to create the global Workforce OAuth client (replace `YOUR_PROJECT_ID` with your GCP project ID): + ```bash + gcloud iam oauth-clients create cs-wif-oauth-client \ + --project=YOUR_PROJECT_ID \ + --location=global \ + --client-type="confidential-client" \ + --display-name="Creative Studio IAP WIF Client" \ + --allowed-grant-types="authorization-code-grant" \ + --allowed-scopes="openid,email,https://www.googleapis.com/auth/cloud-platform" \ + --allowed-redirect-uris="https://example.com/callback" + + ``` +2. Describe the newly created client to retrieve the system-generated **Client ID**: + ```bash + gcloud iam oauth-clients describe cs-wif-oauth-client \ + --project=YOUR_PROJECT_ID \ + --location=global + ``` + *Copy the **`clientId`** value from the output (it will look like a UUID, e.g., `ae1b3ac35-542f-4a97-b7ac-be4fb6160c2e`).* + +3. Update the OAuth client's redirect URI to use its own generated Client ID redirect handler: + ```bash + gcloud iam oauth-clients update cs-wif-oauth-client \ + --project=YOUR_PROJECT_ID \ + --location=global \ + --allowed-redirect-uris="https://iap.googleapis.com/v1/oauth/clientIds/YOUR_GENERATED_CLIENT_ID:handleRedirect" + ``` + *(Replace `YOUR_GENERATED_CLIENT_ID` with the Client ID copied in the previous step).* + +4. Generate the Client Secret for this Workforce client: + ```bash + gcloud iam oauth-clients credentials create cs-wif-oauth-credential \ + --oauth-client=cs-wif-oauth-client \ + --project=YOUR_PROJECT_ID \ + --location=global + ``` +5. Retrieve and save the generated **Client Secret**: + ```bash + gcloud iam oauth-clients credentials describe cs-wif-oauth-credential \ + --oauth-client=cs-wif-oauth-client \ + --project=YOUR_PROJECT_ID \ + --location=global + ``` + *Copy the **`clientSecret`** value from the output (it will look like a Google secret key starting with `GOCSPX-`).* + + +--- + +### Step 3: Populate Terraform Configuration (`dev-infra.tfvars`) +To enable WIF and IAP, configure these variables in your target environment's `.tfvars` file (e.g., `infra/environments/dev-infra/dev-infra.tfvars`): + +```hcl +# --- IAP Credentials --- +iap_oauth2_client_id = "YOUR_GOOGLE_CLIENT_ID" +iap_oauth2_client_secret = "YOUR_GOOGLE_CLIENT_SECRET" +domain_name = "YOUR_DOMAIN_NAME" # (e.g., "creative.yourcompany.com" or "8.8.8.8.nip.io") + +# --- Workforce Identity Federation (WIF) --- +org_id = "YOUR_GCP_ORGANIZATION_ID_NUMERIC" +entra_client_id = "YOUR_MICROSOFT_ENTRA_CLIENT_ID" +entra_tenant_id = "YOUR_MICROSOFT_ENTRA_TENANT_ID" +entra_client_secret = "YOUR_MICROSOFT_ENTRA_CLIENT_SECRET" + +# --- Authorization Access Rules --- + +# You can authorize individual Entra ID users, entire domains, or all users in the pool. +# Use wildcard '*' to allow any user authenticated via the Workforce pool to access the application: +iap_access_members = [ + "principalSet://iam.googleapis.com/locations/global/workforcePools/cs-workforce-pool/*" +] +``` + +--- + +### Step 4: Deploy using Terraform +Initialize and apply the Terraform configuration inside your environment directory: +```bash +# Initialize Terraform +terraform init + +# Apply the infrastructure configuration +terraform apply -var-file=dev-infra.tfvars +``` +Terraform will automatically: +1. Create a Workforce Identity Pool (`cs-workforce-pool`). +2. Register Microsoft Entra ID as the OIDC provider inside the pool. +3. Configure the HTTP Load Balancer with Managed SSL Certificates. +4. Enable IAP on the Load Balancer's backend and associate it with the Workforce pool. +5. Create the IAM bindings granting access to the workforce principals. + +--- + +### Step 5: Test and Access the App +1. Wait 5-10 minutes for your SSL certificates and Load Balancer to propagate globally. +2. Navigate to your domain in the browser (e.g., `https://YOUR_DOMAIN_NAME`). +3. You will be redirected to Google's Workforce Single Sign-On page. +4. Select your identity provider or log in. It will redirect you to Microsoft Entra's sign-in screen. +5. Enter your corporate email and password. +6. Once authenticated, Entra will redirect you back to Google IAP, and IAP will let you access Creative Studio! diff --git a/backend/Dockerfile b/backend/Dockerfile index 0d861631..932886c5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1.4 # Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -25,16 +26,13 @@ ENV UV_COMPILE_BYTECODE=1 ENV UV_LINK_MODE=copy # Install the project's dependencies using the lockfile and settings -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project --no-dev +COPY uv.lock pyproject.toml /app/ +RUN uv sync --locked --no-install-project --no-dev # Then, add the rest of the project source code and install it # Installing separately from its dependencies allows optimal layer caching COPY . /app -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev +RUN uv sync --locked --no-dev # Place executables in the environment at the front of the path ENV PATH="/app/.venv/bin:$PATH" diff --git a/backend/main.py b/backend/main.py index 5858ecb0..49cab873 100644 --- a/backend/main.py +++ b/backend/main.py @@ -60,6 +60,7 @@ from src.system_settings.system_settings_controller import ( router as system_settings_router, ) +from src.auth.auth_controller import router as auth_router def configure_cors(app): @@ -84,7 +85,7 @@ def configure_cors(app): app.add_middleware( CORSMiddleware, allow_origins=allowed_origins, - allow_credentials=environment == "production", + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @@ -185,3 +186,4 @@ def version(): app.include_router(workflows_executor_router) app.include_router(workbench_router) app.include_router(system_settings_router) +app.include_router(auth_router) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5bba4cfa..6b961f7c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -55,6 +55,8 @@ dependencies = [ "google-cloud-workflows>=1.19.0", "google-api-python-client>=2.187.0", "httpx>=0.27.0", + "pyjwt>=2.8.0", + "cryptography>=42.0.0", ] [project.optional-dependencies] diff --git a/backend/src/auth/auth_controller.py b/backend/src/auth/auth_controller.py new file mode 100644 index 00000000..c729f484 --- /dev/null +++ b/backend/src/auth/auth_controller.py @@ -0,0 +1,29 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from fastapi import APIRouter, status +from fastapi.responses import RedirectResponse + +router = APIRouter( + prefix="/api/auth", + tags=["Auth"], +) + + +@router.post("/logout") +async def logout(): + return RedirectResponse( + url="/_gcp_iap/clear_login_cookie", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) diff --git a/backend/src/auth/auth_guard.py b/backend/src/auth/auth_guard.py index 9349e326..57cb427a 100644 --- a/backend/src/auth/auth_guard.py +++ b/backend/src/auth/auth_guard.py @@ -17,10 +17,10 @@ import asyncio import logging -from fastapi import Depends, HTTPException, status -from fastapi.security import OAuth2PasswordBearer +from fastapi import Depends, HTTPException, status, Request, Header from firebase_admin import auth + # --- Google Auth for Identity Platform --- from google.auth.transport import requests as google_auth_requests from google.oauth2 import id_token @@ -32,61 +32,96 @@ # Initialize the service once to be used by dependencies. # user_service = UserService() <-- REMOVED -# This scheme will require the client to send a token in the Authorization -# header. It tells FastAPI how to find the token but doesn't validate it -# itself. -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - +import fastapi.security.utils logger = logging.getLogger(__name__) +async def get_iap_jwt( + request: Request, x_goog_iap_jwt_assertion: str | None = Header(None) +) -> str | None: + """Extracts the IAP JWT assertion. In local environment, this is optional.""" + if config_service.ENVIRONMENT == "local": + return x_goog_iap_jwt_assertion or "mock_local_token" + if not x_goog_iap_jwt_assertion: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized: Missing X-Goog-Iap-Jwt-Assertion header.", + ) + return x_goog_iap_jwt_assertion + + async def get_current_user( - token: str = Depends(oauth2_scheme), + request: Request, + token: str = Depends(get_iap_jwt), user_service: UserService = Depends(UserService), ) -> UserModel: """Dependency that handles the entire authentication and user - provisioning flow. + provisioning flow via Identity-Aware Proxy (IAP). - 1. Verifies the Firebase ID token. - 2. Extracts user information (id, email). - 3. Checks if a user document exists in Firestore. - 4. If the user is new, creates their document ("Just-In-Time Provisioning"). + 1. Checks if running locally to bypass verification. + 2. Verifies the Google-signed IAP JWT token. + 3. Extracts user information (email, name, picture). + 4. If the user is new, creates their profile JIT. 5. Returns a Pydantic model with the user's data. """ try: - decoded_token = {} if config_service.ENVIRONMENT == "local": - # --- Local: Use Firebase Auth --- - # Verifies the token using the standard Firebase Admin SDK method. - logger.info("Verifying token using Firebase Admin SDK...") - decoded_token = await asyncio.to_thread(auth.verify_id_token, token) - else: - # --- Development/Production: Use Google Identity Platform - # (OIDC) --- - # Verifies the Google-issued OIDC ID token. The audience must be the - # OAuth 2.0 client ID of the Identity Platform-protected resource. - google_token_audience = config_service.GOOGLE_TOKEN_AUDIENCE - decoded_token = await asyncio.to_thread( - id_token.verify_oauth2_token, - token, - google_auth_requests.Request(), - audience=google_token_audience, + # Local Dev Bypass: Use mock user + mock_email = request.headers.get( + "X-Mock-User-Email", "local-dev@example.com" ) + mock_name = request.headers.get( + "X-Mock-User-Name", "Local Dev User" + ) + user_doc = await user_service.create_user_if_not_exists( + email=mock_email, + name=mock_name, + picture="", + ) + return user_doc + + # Verify Google-signed IAP JWT assertion + decoded_token = await asyncio.to_thread( + id_token.verify_token, + token, + google_auth_requests.Request(), + audience=config_service.IAP_EXPECTED_AUDIENCE, + certs_url="https://www.gstatic.com/iap/verify/public_key", + ) + + logger.info("Decoded IAP Token Claims: %s", list(decoded_token.keys())) + logger.info( + "Decoded Token values - email: %s, sub: %s, hd: %s", + decoded_token.get("email"), + decoded_token.get("sub"), + decoded_token.get("hd"), + ) email = decoded_token.get("email") - name = decoded_token.get("name") + # In Workforce Identity Federation, the username/email might be in a different claim. + # Fall back to preferred_username, upn, or subject (final fallback) if email claim is not present. + if not email: + email = decoded_token.get("preferred_username") + if not email: + email = decoded_token.get("upn") + if not email: + email = decoded_token.get("sub") + + name = decoded_token.get( + "name", email.split("@")[0] if email and "@" in email else "User" + ) picture = decoded_token.get("picture", "") + token_info_hd = decoded_token.get("hd") + if not token_info_hd and email and "@" in email: + token_info_hd = email.split("@")[-1] # Restrict by particular organizations if it's a closed environment if not email: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail=( - "Forbidden: User identity could not be confirmed from " - "token." - ), + detail="Forbidden: User identity could not be confirmed from IAP token.", ) # If ALLOWED_ORGS is configured, check the user's organization. @@ -127,35 +162,19 @@ async def get_current_user( return user_doc - except auth.ExpiredIdTokenError as exc: - logger.error( - "[get_current_user - auth.ExpiredIdTokenError] for %s", email - ) + except ValueError as exc: + logger.error("[get_current_user - Invalid IAP Token]: %s", exc) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication token has expired.", + detail=f"Invalid IAP authentication token: {exc}", ) from exc - except auth.InvalidIdTokenError as e: - logger.error( - "[get_current_user - auth.InvalidIdTokenError] for %s: %s", - email, - e, - ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=f"Invalid authentication token: {e}", - ) from e except HTTPException as e: - logger.error("[get_current_user - Exception]: %s", e) + logger.error("[get_current_user - HTTPException]: %s", e) raise e except Exception as e: - logger.error("[get_current_user - Exception]: %s", e) + logger.error("[get_current_user - Unexpected Exception]: %s", e) raise HTTPException( - status_code=getattr( - e, - "status_code", - status.HTTP_500_INTERNAL_SERVER_ERROR, - ), + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"An unexpected error occurred during authentication: {e}", ) from e diff --git a/backend/src/config/config_service.py b/backend/src/config/config_service.py index 7b4eef1d..f5181957 100644 --- a/backend/src/config/config_service.py +++ b/backend/src/config/config_service.py @@ -53,6 +53,9 @@ class ConfigService(BaseSettings): default="", alias="IDENTITY_PLATFORM_ALLOWED_ORGS" ) + # --- Google Cloud IAP --- + IAP_EXPECTED_AUDIENCE: str = "" + # --- Storage --- # The defaults will be set in the validator below to prevent recursion. GENMEDIA_BUCKET: str = "" diff --git a/backend/src/images/imagen_service.py b/backend/src/images/imagen_service.py index 1ee8f3ca..4990a282 100644 --- a/backend/src/images/imagen_service.py +++ b/backend/src/images/imagen_service.py @@ -1644,7 +1644,14 @@ async def upscale_image( client = GenAIModelSetup.init() try: # --- Step 1: Perform the Upscale API Call --- - image_for_api = types.Image(gcs_uri=request_dto.user_image) + if request_dto.user_image.startswith("gs://"): + image_for_api = types.Image(gcs_uri=request_dto.user_image) + else: + import base64 + + image_for_api = types.Image( + image_bytes=base64.b64decode(request_dto.user_image) + ) response = client.models.upscale_image( model=GenerationModelEnum.IMAGEN_4_UPSCALE_PREVIEW.value, diff --git a/backend/tests/auth/test_auth_guard.py b/backend/tests/auth/test_auth_guard.py index 1782eed6..28f65679 100644 --- a/backend/tests/auth/test_auth_guard.py +++ b/backend/tests/auth/test_auth_guard.py @@ -12,12 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -from unittest.mock import AsyncMock, patch - +from unittest.mock import AsyncMock, patch, MagicMock import pytest -from fastapi import HTTPException - -from src.auth.auth_guard import RoleChecker, get_current_user +from fastapi import HTTPException, Request +from src.auth.auth_guard import RoleChecker, get_current_user, get_iap_jwt from src.config.config_service import config_service from src.users.user_model import UserModel, UserRoleEnum @@ -36,77 +34,342 @@ def fixture_mock_user_service(): class TestGetCurrentUser: - """Tests for get_current_user dependency.""" + """Tests for get_current_user dependency and IAP authentication.""" @pytest.mark.anyio - @patch("src.auth.auth_guard.auth.verify_id_token") - async def test_get_current_user_local_success( - self, mock_verify, mock_user_service + async def test_get_iap_jwt_missing_header_prod(self): + # Setup: non-local env + config_service.ENVIRONMENT = "production" + mock_request = MagicMock(spec=Request) + + with pytest.raises(HTTPException) as exc_info: + await get_iap_jwt( + request=mock_request, x_goog_iap_jwt_assertion=None + ) + + assert exc_info.value.status_code == 401 + assert ( + "Missing X-Goog-Iap-Jwt-Assertion header" in exc_info.value.detail + ) + + @pytest.mark.anyio + async def test_get_iap_jwt_local_default(self): + # Setup: local env + config_service.ENVIRONMENT = "local" + mock_request = MagicMock(spec=Request) + + token = await get_iap_jwt( + request=mock_request, x_goog_iap_jwt_assertion=None + ) + assert token == "mock_local_token" + + @pytest.mark.anyio + async def test_get_current_user_local_dev_bypass_default( + self, mock_user_service + ): + config_service.ENVIRONMENT = "local" + mock_request = MagicMock(spec=Request) + mock_request.headers = {} # No mock headers + + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=1, + email="local-dev@example.com", + name="Local Dev User", + roles=["user"], + ) + + user = await get_current_user( + request=mock_request, + token="mock_local_token", + user_service=mock_user_service, + ) + + assert user.email == "local-dev@example.com" + assert user.name == "Local Dev User" + mock_user_service.create_user_if_not_exists.assert_called_once_with( + email="local-dev@example.com", name="Local Dev User", picture="" + ) + + @pytest.mark.anyio + async def test_get_current_user_local_dev_bypass_custom_headers( + self, mock_user_service ): - # Setup: Local environment config_service.ENVIRONMENT = "local" + mock_request = MagicMock(spec=Request) + mock_request.headers = { + "X-Mock-User-Email": "custom-dev@example.com", + "X-Mock-User-Name": "Custom Dev User", + } + + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=2, + email="custom-dev@example.com", + name="Custom Dev User", + roles=["user"], + ) + + user = await get_current_user( + request=mock_request, + token="mock_local_token", + user_service=mock_user_service, + ) + + assert user.email == "custom-dev@example.com" + assert user.name == "Custom Dev User" + mock_user_service.create_user_if_not_exists.assert_called_once_with( + email="custom-dev@example.com", name="Custom Dev User", picture="" + ) + + @pytest.mark.anyio + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_success( + self, mock_verify, mock_user_service + ): + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" config_service.ALLOWED_ORGS_STR = "" - # Mock token verification + mock_request = MagicMock(spec=Request) mock_verify.return_value = { - "email": "test@example.com", - "name": "Test User", + "email": "iap_user@example.com", + "name": "IAP User", "picture": "http://example.com/pic.jpg", "hd": "example.com", } + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=3, email="iap_user@example.com", name="IAP User", roles=["user"] + ) + user = await get_current_user( - token="valid_token", + request=mock_request, + token="valid_iap_jwt", user_service=mock_user_service, ) - assert user.email == "test@example.com" - assert user.name == "Test User" + assert user.email == "iap_user@example.com" mock_user_service.create_user_if_not_exists.assert_called_once_with( - email="test@example.com", - name="Test User", + email="iap_user@example.com", + name="IAP User", picture="http://example.com/pic.jpg", ) @pytest.mark.anyio - @patch("src.auth.auth_guard.auth.verify_id_token") - async def test_get_current_user_no_email( + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_success_fallback_to_sub( self, mock_verify, mock_user_service ): - config_service.ENVIRONMENT = "local" - mock_verify.return_value = {"name": "Test User"} # Missing email + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" + config_service.ALLOWED_ORGS_STR = "" + + mock_request = MagicMock(spec=Request) + mock_verify.return_value = { + "sub": "principal://iam.googleapis.com/locations/global/workforcePools/pool/subject/user123", + "name": "Federated User", + } + + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=4, + email="principal://iam.googleapis.com/locations/global/workforcePools/pool/subject/user123", + name="Federated User", + roles=["user"], + ) + + user = await get_current_user( + request=mock_request, + token="valid_iap_jwt", + user_service=mock_user_service, + ) + + assert ( + user.email + == "principal://iam.googleapis.com/locations/global/workforcePools/pool/subject/user123" + ) + mock_user_service.create_user_if_not_exists.assert_called_once_with( + email="principal://iam.googleapis.com/locations/global/workforcePools/pool/subject/user123", + name="Federated User", + picture="", + ) + + @pytest.mark.anyio + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_invalid_token( + self, mock_verify, mock_user_service + ): + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" + + mock_request = MagicMock(spec=Request) + mock_verify.side_effect = ValueError("Invalid signature") with pytest.raises(HTTPException) as exc_info: await get_current_user( - token="valid_token", user_service=mock_user_service + request=mock_request, + token="invalid_token", + user_service=mock_user_service, ) - assert exc_info.value.status_code == 403 - assert "User identity could not be confirmed" in exc_info.value.detail + assert exc_info.value.status_code == 401 + assert "Invalid IAP authentication token" in exc_info.value.detail @pytest.mark.anyio - @patch("src.auth.auth_guard.auth.verify_id_token") - async def test_get_current_user_allowed_orgs_fail( - self, - mock_verify, - mock_user_service, + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_allowed_orgs_fail( + self, mock_verify, mock_user_service ): - config_service.ENVIRONMENT = "local" + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" config_service.ALLOWED_ORGS_STR = "allowed.com" + mock_request = MagicMock(spec=Request) mock_verify.return_value = { - "email": "test@example.com", - "name": "Test User", + "email": "user@forbidden.com", + "name": "Forbidden User", "hd": "forbidden.com", } with pytest.raises(HTTPException) as exc_info: await get_current_user( - token="valid_token", user_service=mock_user_service + request=mock_request, + token="valid_token", + user_service=mock_user_service, ) assert exc_info.value.status_code == 401 assert "not part of an allowed organization" in exc_info.value.detail + config_service.ALLOWED_ORGS_STR = "" + + @pytest.mark.anyio + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_success_fallback_preferred_username( + self, mock_verify, mock_user_service + ): + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" + config_service.ALLOWED_ORGS_STR = "" + + mock_request = MagicMock(spec=Request) + mock_verify.return_value = { + "preferred_username": "preferred_user@example.com", + "name": "Preferred User", + } + + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=5, + email="preferred_user@example.com", + name="Preferred User", + roles=["user"], + ) + + user = await get_current_user( + request=mock_request, + token="valid_iap_jwt", + user_service=mock_user_service, + ) + + assert user.email == "preferred_user@example.com" + mock_user_service.create_user_if_not_exists.assert_called_once_with( + email="preferred_user@example.com", + name="Preferred User", + picture="", + ) + + @pytest.mark.anyio + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_success_fallback_upn( + self, mock_verify, mock_user_service + ): + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" + config_service.ALLOWED_ORGS_STR = "" + + mock_request = MagicMock(spec=Request) + mock_verify.return_value = { + "upn": "upn_user@example.com", + "name": "UPN User", + } + + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=6, + email="upn_user@example.com", + name="UPN User", + roles=["user"], + ) + + user = await get_current_user( + request=mock_request, + token="valid_iap_jwt", + user_service=mock_user_service, + ) + + assert user.email == "upn_user@example.com" + mock_user_service.create_user_if_not_exists.assert_called_once_with( + email="upn_user@example.com", + name="UPN User", + picture="", + ) + + @pytest.mark.anyio + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_success_fallback_precedence_preferred_username( + self, mock_verify, mock_user_service + ): + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" + config_service.ALLOWED_ORGS_STR = "" + + mock_request = MagicMock(spec=Request) + mock_verify.return_value = { + "preferred_username": "preferred@example.com", + "upn": "upn@example.com", + "sub": "sub-id", + } + + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=7, + email="preferred@example.com", + name="User", + roles=["user"], + ) + + user = await get_current_user( + request=mock_request, + token="valid_iap_jwt", + user_service=mock_user_service, + ) + + assert user.email == "preferred@example.com" + + @pytest.mark.anyio + @patch("src.auth.auth_guard.id_token.verify_token") + async def test_get_current_user_iap_success_fallback_precedence_upn( + self, mock_verify, mock_user_service + ): + config_service.ENVIRONMENT = "production" + config_service.IAP_EXPECTED_AUDIENCE = "test-iap-audience" + config_service.ALLOWED_ORGS_STR = "" + + mock_request = MagicMock(spec=Request) + mock_verify.return_value = { + "upn": "upn@example.com", + "sub": "sub-id", + } + + mock_user_service.create_user_if_not_exists.return_value = UserModel( + id=8, + email="upn@example.com", + name="User", + roles=["user"], + ) + + user = await get_current_user( + request=mock_request, + token="valid_iap_jwt", + user_service=mock_user_service, + ) + + assert user.email == "upn@example.com" class TestRoleChecker: diff --git a/backend/tests/brand_guidelines/test_brand_guideline_bug.py b/backend/tests/brand_guidelines/test_brand_guideline_bug.py new file mode 100644 index 00000000..d8cb956d --- /dev/null +++ b/backend/tests/brand_guidelines/test_brand_guideline_bug.py @@ -0,0 +1,71 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Temporary tests to verify brand guideline serialization bugs.""" + +from unittest.mock import MagicMock, patch +import pytest +from pydantic import ValidationError +from src.multimodal.gemini_service import GeminiService + + +@pytest.fixture(name="gemini_service") +def fixture_gemini_service(): + with patch( + "src.multimodal.gemini_service.GeminiModelSetup.init" + ) as mock_init: + mock_client = MagicMock() + mock_init.return_value = mock_client + service = GeminiService() + service.client = mock_client + return service + + +def test_aggregate_brand_info_one_item_missing_name(gemini_service): + # Simulate partial result from Gemini that is missing 'name' + partial_results = [ + { + "colorPalette": ["#000000"], + "toneOfVoiceSummary": "cool", + "visualStyleSummary": "sleek", + } + ] + # This should raise ValidationError because 'name' is required in BrandGuidelineModel + with pytest.raises(ValidationError) as exc_info: + gemini_service.aggregate_brand_info(partial_results) + + assert "name" in str(exc_info.value) + print( + "\n[CONFIRMED] ValidationError raised as expected when 'name' is missing in single partial result." + ) + + +def test_aggregate_brand_info_multiple_items_gemini_missing_name( + gemini_service, +): + partial_results = [ + {"colorPalette": ["#000000"], "toneOfVoiceSummary": "cool"}, + {"colorPalette": ["#FFFFFF"], "visualStyleSummary": "sleek"}, + ] + + # Mock Gemini response to NOT include 'name' + mock_response = MagicMock() + mock_response.text = '{"colorPalette": ["#000000", "#FFFFFF"], "toneOfVoiceSummary": "cool", "visualStyleSummary": "sleek"}' + gemini_service.client.models.generate_content.return_value = mock_response + + # This returns None because ValidationError is caught inside aggregate_brand_info + res = gemini_service.aggregate_brand_info(partial_results) + assert res is None + print( + "\n[CONFIRMED] aggregate_brand_info returned None as expected due to internal ValidationError (missing 'name')." + ) diff --git a/backend/tests/brand_guidelines/test_brand_guideline_service.py b/backend/tests/brand_guidelines/test_brand_guideline_service.py new file mode 100644 index 00000000..0e117e38 --- /dev/null +++ b/backend/tests/brand_guidelines/test_brand_guideline_service.py @@ -0,0 +1,82 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for Brand Guideline Service Background Worker.""" + +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from src.brand_guidelines.brand_guideline_service import ( + _process_brand_guideline_in_background, +) +from src.common.schema.media_item_model import JobStatusEnum + + +@pytest.mark.xfail( + reason="Exposes download failure handling bug in brand guideline worker" +) +@patch("src.database.WorkerDatabase") +def test_process_brand_guideline_download_failure(mock_worker_db_class): + # Mock WorkerDatabase Context + mock_db_context = AsyncMock() + mock_db_factory = MagicMock(return_value=mock_db_context) + mock_worker_db_class.return_value.__aenter__.return_value = mock_db_factory + + # Patch dependencies inside the worker + with ( + patch( + "src.brand_guidelines.brand_guideline_service.BrandGuidelineRepository" + ) as mock_repo_class, + patch( + "src.brand_guidelines.brand_guideline_service.GcsService" + ) as mock_gcs_class, + patch( + "src.brand_guidelines.brand_guideline_service.GeminiService" + ) as mock_gemini_class, + ): + mock_repo = AsyncMock() + mock_repo_class.return_value = mock_repo + + mock_gcs = MagicMock() + mock_gcs_class.return_value = mock_gcs + # Simulate download failure by returning None + mock_gcs.download_bytes_from_gcs.return_value = None + mock_gcs.upload_bytes_to_gcs.return_value = None + + mock_gemini = MagicMock() + mock_gemini_class.return_value = mock_gemini + + # Call the worker + _process_brand_guideline_in_background( + guideline_id=1, + name="Test Guideline", + original_filename="test.pdf", + source_gcs_uri="gs://bucket/test.pdf", + workspace_id=1, + ) + + # Assertions + # 1. download_bytes_from_gcs should have been called + mock_gcs.download_bytes_from_gcs.assert_called_once_with( + "gs://bucket/test.pdf" + ) + + # 2. upload_bytes_to_gcs should NOT have been called because download failed + mock_gcs.upload_bytes_to_gcs.assert_not_called() + + # 3. The repository should have been updated with FAILED status + mock_repo.update.assert_called_once() + call_args = mock_repo.update.call_args + assert call_args is not None + assert call_args[0][0] == 1 # guideline_id + assert call_args[0][1]["status"] == JobStatusEnum.FAILED + assert "Failed to download" in call_args[0][1]["error_message"] diff --git a/backend/tests/common/test_media_utils.py b/backend/tests/common/test_media_utils.py index ddf00d1c..c0d39a76 100644 --- a/backend/tests/common/test_media_utils.py +++ b/backend/tests/common/test_media_utils.py @@ -189,3 +189,40 @@ def test_concatenate_videos_called_process_error(): ["/tmp/v1.mp4", "/tmp/v2.mp4"], "/tmp/output.mp4" ) assert res is None + + +def test_get_video_dimensions_ffprobe_not_found(): + with patch("src.common.media_utils.subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError() + import pytest + with pytest.raises(FileNotFoundError): + get_video_dimensions("/tmp/video.mp4") + + +def test_get_video_dimensions_called_process_error(): + with patch("src.common.media_utils.subprocess.run") as mock_run: + import subprocess + import pytest + mock_run.side_effect = subprocess.CalledProcessError( + 1, "cmd", stderr="error" + ) + with pytest.raises(subprocess.CalledProcessError): + get_video_dimensions("/tmp/video.mp4") + + +def test_get_video_dimensions_empty_streams(): + with patch("src.common.media_utils.subprocess.run") as mock_run: + mock_run.return_value.stdout = '{"streams": []}' + import pytest + with pytest.raises(IndexError): + get_video_dimensions("/tmp/video.mp4") + + +def test_get_video_dimensions_missing_streams_key(): + with patch("src.common.media_utils.subprocess.run") as mock_run: + mock_run.return_value.stdout = "{}" + import pytest + with pytest.raises(KeyError): + get_video_dimensions("/tmp/video.mp4") + + diff --git a/backend/tests/common/test_storage_service.py b/backend/tests/common/test_storage_service.py index 6eb399c1..e2141650 100644 --- a/backend/tests/common/test_storage_service.py +++ b/backend/tests/common/test_storage_service.py @@ -257,3 +257,30 @@ def test_store_to_gcs_decode_success(gcs_service): def test_store_to_gcs_invalid_type(gcs_service): res = gcs_service.store_to_gcs("folder", "file.txt", "text/plain", 123) assert res == "" + + +@pytest.mark.xfail(reason="Exposes custom bucket bug in store_to_gcs") +def test_store_to_gcs_custom_bucket_bug(gcs_service): + mock_other_bucket = MagicMock() + mock_blob = MagicMock() + mock_other_bucket.blob.return_value = mock_blob + gcs_service.client.bucket.return_value = mock_other_bucket + + res = gcs_service.store_to_gcs( + "folder", + "file.txt", + "text/plain", + b"bytes", + bucket_name="other-bucket", + ) + + # It should return the correct GCS URI + assert res == "gs://other-bucket/folder/file.txt" + + # It should have called client.bucket with "other-bucket" + gcs_service.client.bucket.assert_called_with("other-bucket") + + # It should have called blob on the other bucket + mock_other_bucket.blob.assert_called_once_with("folder/file.txt") + + diff --git a/backend/tests/images/test_imagen_service.py b/backend/tests/images/test_imagen_service.py index 04543535..a3617700 100644 --- a/backend/tests/images/test_imagen_service.py +++ b/backend/tests/images/test_imagen_service.py @@ -178,6 +178,43 @@ async def test_upscale_image_success(self, imagen_service): assert result is not None + @pytest.mark.anyio + async def test_upscale_image_base64_success(self, imagen_service): + import base64 + base64_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + request_dto = UpscaleImagenDto( + user_image=base64_image, + upscale_factor="x4", + include_rai_reason=False, + enhance_input_image=False, + image_preservation_factor=1.0, + ) + + with patch( + "src.images.imagen_service.GenAIModelSetup.init" + ) as mock_init: + mock_client = MagicMock() + mock_init.return_value = mock_client + + mock_response = MagicMock() + mock_generated_image = MagicMock() + mock_generated_image.image.image_bytes = b"fake_upscaled_bytes" + mock_generated_image.rai_filtered_reason = "" + mock_response.generated_images = [mock_generated_image] + + mock_client.models.upscale_image.return_value = mock_response + + # Call + result = await imagen_service.upscale_image(request_dto) + + assert result is not None + # Assert that the SDK was called with image_bytes, not gcs_uri + call_args = mock_client.models.upscale_image.call_args + assert call_args is not None + called_image = call_args.kwargs["image"] + assert called_image.image_bytes == base64.b64decode(base64_image) + assert called_image.gcs_uri is None + @pytest.mark.anyio async def test_upscale_image_rai_filtered(self, imagen_service): from src.images.dto.upscale_imagen_dto import UpscaleImagenDto diff --git a/backend/tests/users/test_user_repository.py b/backend/tests/users/test_user_repository.py new file mode 100644 index 00000000..7234686b --- /dev/null +++ b/backend/tests/users/test_user_repository.py @@ -0,0 +1,132 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for User Repository.""" + +import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.users.dto.user_search_dto import UserSearchDto +from src.users.repository.user_repository import UserRepository +from src.users.user_model import User, UserRoleEnum + + +def get_dummy_user(**kwargs): + now = datetime.datetime.now(datetime.UTC) + defaults = { + "id": 1, + "email": "user@example.com", + "name": "Test User", + "picture": "http://example.com/pic.jpg", + "roles": [UserRoleEnum.USER.value], + "created_at": now, + "updated_at": now, + "deleted_at": None, + } + defaults.update(kwargs) + return User(**defaults) + + +@pytest.mark.anyio +async def test_get_by_email_success(): + mock_db = AsyncMock() + mock_result = MagicMock() + mock_user = get_dummy_user(email="test@example.com") + mock_result.scalar_one_or_none.return_value = mock_user + mock_db.execute.return_value = mock_result + + repo = UserRepository(db=mock_db) + response = await repo.get_by_email(email="test@example.com") + + assert response is not None + assert response.email == "test@example.com" + assert response.id == 1 + mock_db.execute.assert_called_once() + + +@pytest.mark.anyio +async def test_get_by_email_not_found(): + mock_db = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_db.execute.return_value = mock_result + + repo = UserRepository(db=mock_db) + response = await repo.get_by_email(email="nonexistent@example.com") + + assert response is None + mock_db.execute.assert_called_once() + + +@pytest.mark.anyio +async def test_query_no_filters(): + mock_db = AsyncMock() + + mock_count_result = MagicMock() + mock_count_result.scalar_one.return_value = 5 + + mock_users_result = MagicMock() + mock_user = get_dummy_user(id=1) + mock_users_result.scalars().all.return_value = [mock_user] + + mock_db.execute.side_effect = [mock_count_result, mock_users_result] + + repo = UserRepository(db=mock_db) + search_dto = UserSearchDto(limit=10, offset=0) + response = await repo.query(search_dto=search_dto) + + assert response is not None + assert response.count == 5 + assert response.page == 1 + assert response.page_size == 10 + assert response.total_pages == 1 + assert len(response.data) == 1 + assert response.data[0].id == 1 + + assert mock_db.execute.call_count == 2 + + +@pytest.mark.anyio +async def test_query_with_filters(): + mock_db = AsyncMock() + + mock_count_result = MagicMock() + mock_count_result.scalar_one.return_value = 1 + + mock_users_result = MagicMock() + mock_user = get_dummy_user(id=2, email="admin@example.com", roles=[UserRoleEnum.ADMIN.value]) + mock_users_result.scalars().all.return_value = [mock_user] + + mock_db.execute.side_effect = [mock_count_result, mock_users_result] + + repo = UserRepository(db=mock_db) + search_dto = UserSearchDto( + email="admin", + role=UserRoleEnum.ADMIN, + limit=5, + offset=0, + include_deleted=True, + ) + response = await repo.query(search_dto=search_dto) + + assert response is not None + assert response.count == 1 + assert response.page == 1 + assert response.page_size == 5 + assert response.total_pages == 1 + assert len(response.data) == 1 + assert response.data[0].email == "admin@example.com" + + assert mock_db.execute.call_count == 2 diff --git a/backend/tests/videos/test_veo_service.py b/backend/tests/videos/test_veo_service.py index e2386fff..1d8175a7 100644 --- a/backend/tests/videos/test_veo_service.py +++ b/backend/tests/videos/test_veo_service.py @@ -506,6 +506,104 @@ def test_process_video_concatenation_in_background_sync( mock_concat.assert_called_once() mock_media_repo.update.assert_called_once() + @patch("src.database.WorkerDatabase") + @patch("src.videos.veo_service.concatenate_videos") + @patch("src.videos.veo_service.generate_thumbnail") + @pytest.mark.xfail( + reason="Exposes different bucket bug in video concatenation" + ) + def test_process_video_concatenation_in_background_different_bucket( + self, + mock_thumb, + mock_concat, + mock_worker_db_class, + ): + from src.videos.dto.concatenate_videos_dto import ( + ConcatenateVideosDto, + ConcatenationInput, + ) + + request_dto = ConcatenateVideosDto( + workspace_id=1, + name="Concat Video", + inputs=[ + ConcatenationInput(type="media_item", id=1), + ConcatenationInput(type="media_item", id=2), + ], + ) + + # Mock WorkerDatabase Context + mock_db_context = AsyncMock() + mock_db_factory = MagicMock(return_value=mock_db_context) + mock_worker_db_class.return_value.__aenter__.return_value = ( + mock_db_factory + ) + + mock_thumb.return_value = "/tmp/thumbnails/thumb.png" + mock_concat.return_value = "/tmp/concat.mp4" + + # Patch Repos inside execution + with ( + patch( + "src.videos.veo_service.MediaRepository", + ) as mock_media_repo_class, + patch( + "src.videos.veo_service.GcsService", + ) as mock_gcs_class, + ): + mock_media_repo = AsyncMock() + mock_media_repo_class.return_value = mock_media_repo + + # Setup mock assets to return for downloading - using a DIFFERENT bucket + mock_item1 = MediaItemModel( + id=1, + workspace_id=1, + user_id=1, + user_email="t@t.com", + mime_type=MimeTypeEnum.VIDEO_MP4, + model=GenerationModelEnum.VEO_3_QUALITY, + aspect_ratio="16:9", + gcs_uris=["gs://different-bucket/1.mp4"], + thumbnail_uris=[], + ) + mock_item2 = MediaItemModel( + id=2, + workspace_id=1, + user_id=1, + user_email="t@t.com", + mime_type=MimeTypeEnum.VIDEO_MP4, + model=GenerationModelEnum.VEO_3_QUALITY, + aspect_ratio="16:9", + gcs_uris=["gs://different-bucket/2.mp4"], + thumbnail_uris=[], + ) + + # get_by_id side_effect to return items + mock_media_repo.get_by_id.side_effect = [mock_item1, mock_item2] + + mock_gcs_service = MagicMock() + mock_gcs_class.return_value = mock_gcs_service + mock_gcs_service.download_from_gcs.return_value = "/tmp/local.mp4" + mock_gcs_service.upload_file_to_gcs.return_value = ( + "gs://bucket/uploaded.mp4" + ) + + # Execute the outer worker function + _process_video_concatenation_in_background( + media_item_id=456, + request_dto=request_dto, + ) + + # Assert that download_from_gcs was NOT called with gs:// URI + calls = mock_gcs_service.download_from_gcs.call_args_list + assert len(calls) == 2 + + gcs_uri_path1 = calls[0].kwargs.get("gcs_uri_path") + assert gcs_uri_path1 is not None + assert not gcs_uri_path1.startswith( + "gs://" + ), f"Should not pass full URI to download_from_gcs, got: {gcs_uri_path1}" + @patch("src.database.WorkerDatabase") @patch("src.videos.veo_service.GenAIModelSetup.init") @patch("src.videos.veo_service.generate_thumbnail") diff --git a/backend/tests/videos/test_veo_service_bug.py b/backend/tests/videos/test_veo_service_bug.py new file mode 100644 index 00000000..2ecb6a34 --- /dev/null +++ b/backend/tests/videos/test_veo_service_bug.py @@ -0,0 +1,136 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for Veo Service bugs.""" + +from unittest.mock import AsyncMock, MagicMock, patch +import pytest + +from src.common.base_dto import GenerationModelEnum +from src.common.schema.media_item_model import ( + AssetRoleEnum, + MediaItemModel, + MimeTypeEnum, + SourceMediaItemLink, + JobStatusEnum, +) +from src.videos.dto.create_veo_dto import CreateVeoDto +from src.videos.veo_service import _process_video_in_background + + +class TestVeoServiceOmniUploadFailure: + + @patch("src.database.WorkerDatabase") + @patch("src.videos.veo_service.GenAIModelSetup.get_omni_client") + @patch("src.videos.veo_service.generate_thumbnail") + @pytest.mark.xfail( + reason="Exposes Omni upload failure bug in veo_service.py" + ) + def test_process_video_in_background_omni_upload_failure( + self, + mock_thumb, + mock_omni_client_init, + mock_worker_db_class, + ): + sample_dto = CreateVeoDto( + workspace_id=1, + prompt="Test Omni", + generation_model=GenerationModelEnum.GEMINI_OMNI, + aspect_ratio="16:9", + duration_seconds=5, + source_media_items=[ + SourceMediaItemLink( + media_item_id=10, + media_index=0, + role=AssetRoleEnum.START_FRAME, + ), + ], + ) + + mock_db_context = AsyncMock() + mock_db_factory = MagicMock(return_value=mock_db_context) + mock_worker_db_class.return_value.__aenter__.return_value = ( + mock_db_factory + ) + + mock_vertex_client = MagicMock() + mock_omni_client_init.return_value = mock_vertex_client + + # Mock Interaction response + mock_interaction = MagicMock() + mock_interaction.id = "interaction-abc" + mock_step = MagicMock() + mock_step.type = "model_output" + mock_content = MagicMock() + mock_content.type = "video" + mock_content.data = "ZmFrZS1vbW5pLXZpZGVvLWJ5dGVz" # base64 string of b"fake-omni-video-bytes" + mock_content.mime_type = "video/mp4" + mock_step.content = [mock_content] + mock_interaction.steps = [mock_step] + mock_vertex_client.interactions.create.return_value = mock_interaction + + mock_thumb.return_value = "/tmp/thumbnails/thumb.png" + + with ( + patch( + "src.videos.veo_service.MediaRepository", + ) as mock_media_repo_class, + patch( + "src.videos.veo_service.GcsService", + ) as mock_gcs_class, + patch( + "src.system_settings.repository.system_settings_repository.SystemSettingsRepository", + ) as mock_settings_repo_class, + ): + mock_media_repo = AsyncMock() + mock_media_repo_class.return_value = mock_media_repo + + mock_settings_repo = AsyncMock() + mock_settings_repo_class.return_value = mock_settings_repo + mock_settings_repo.get_by_id.return_value = None + + mock_item1 = MediaItemModel( + id=10, + workspace_id=1, + user_id=1, + user_email="t@t.com", + mime_type=MimeTypeEnum.IMAGE_PNG, + model=GenerationModelEnum.IMAGEN_3_001, + aspect_ratio="16:9", + gcs_uris=["gs://b/10.png"], + thumbnail_uris=[], + ) + mock_media_repo.get_by_id.side_effect = [mock_item1] + + mock_gcs_service = MagicMock() + mock_gcs_class.return_value = mock_gcs_service + mock_gcs_service.download_from_gcs.return_value = "/tmp/local.mp4" + + # Simulate upload failure by returning None + mock_gcs_service.upload_file_to_gcs.return_value = None + + _process_video_in_background( + media_item_id=1234, + request_dto=sample_dto, + user_email="test@user.com", + ) + + # The job should have failed because upload failed + mock_media_repo.update.assert_called_once() + call_args = mock_media_repo.update.call_args + assert call_args is not None + updated_data = call_args[0][ + 1 + ] # Get the second argument (update_data) + assert updated_data["status"] == JobStatusEnum.FAILED + assert "Failed to upload" in updated_data["error_message"] diff --git a/backend/tests/workflows/test_workflow_concurrency.py b/backend/tests/workflows/test_workflow_concurrency.py new file mode 100644 index 00000000..24b624a2 --- /dev/null +++ b/backend/tests/workflows/test_workflow_concurrency.py @@ -0,0 +1,171 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests to verify concurrency issues in Workflow Service batch execution.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from src.users.user_model import UserModel +from src.workflows.dto.batch_execution_dto import ( + BatchExecutionItemDto, + BatchExecutionRequestDto, +) +from src.workflows.repository.workflow_repository import WorkflowRepository +from src.workflows.repository.workflow_run_repository import ( + WorkflowRunRepository, +) +from src.workflows.schema.workflow_model import Workflow, WorkflowModel +from src.workflows.workflow_service import WorkflowService + + +class ConcurrencyDetectingSession: + """A mock-like session that raises an error if async methods are called concurrently.""" + + def __init__(self, mock_workflow_db_item): + self.active_calls = 0 + self.mock_workflow_db_item = mock_workflow_db_item + + async def _enter_call(self): + if self.active_calls > 0: + raise RuntimeError( + "CONCURRENCY DETECTED: AsyncSession method called concurrently!" + ) + self.active_calls += 1 + + async def _exit_call(self): + self.active_calls -= 1 + + async def execute(self, *args, **kwargs): + await self._enter_call() + try: + await asyncio.sleep(0.05) # Simulate network latency to ensure overlap + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = ( + self.mock_workflow_db_item + ) + return mock_result + finally: + await self._exit_call() + + async def commit(self, *args, **kwargs): + await self._enter_call() + try: + await asyncio.sleep(0.05) + finally: + await self._exit_call() + + async def refresh(self, *args, **kwargs): + await self._enter_call() + try: + await asyncio.sleep(0.05) + finally: + await self._exit_call() + + def add(self, *args, **kwargs): + # add is synchronous, but we can check if it's called during an active async call + if self.active_calls > 0: + raise RuntimeError( + "CONCURRENCY DETECTED: session.add called during active async operation!" + ) + + +@pytest.fixture(name="sample_user") +def fixture_sample_user(): + return UserModel( + id=1, email="test@example.com", name="Test User", roles=["user"] + ) + + +@pytest.mark.anyio +@patch("src.workflows.workflow_service.executions_v1.ExecutionsAsyncClient") +async def test_batch_execute_concurrency_issue( + mock_exec_client_class, + sample_user, +): + # 1. Setup mock GCP response + mock_exec_client = AsyncMock() + mock_exec_client_class.return_value = mock_exec_client + mock_response = MagicMock() + mock_response.name = ( + "projects/p/locations/l/workflows/w/executions/exec-123" + ) + mock_exec_client.create_execution.return_value = mock_response + + # 2. Setup mock Workflow DB item for get_by_id + import datetime + now = datetime.datetime.now(datetime.UTC) + db_workflow = Workflow( + id="id-123", + user_id=sample_user.id, + name="Test Workflow", + steps=[ + { + "step_id": "step1", + "type": "user_input", + "inputs": {}, + "settings": {}, + } + ], + created_at=now, + updated_at=now, + ) + + + # 3. Setup Concurrency Detecting Session + detecting_session = ConcurrencyDetectingSession(db_workflow) + + # 4. Instantiate repositories with the detecting session + # We cast detecting_session to Any to satisfy type checker (it expects AsyncSession) + workflow_repo = WorkflowRepository(db=detecting_session) # type: ignore + workflow_run_repo = WorkflowRunRepository(db=detecting_session) # type: ignore + + # 5. Instantiate service + service = WorkflowService( + workflow_repository=workflow_repo, + workflow_run_repository=workflow_run_repo, + source_asset_service=MagicMock(), # Not used for non-GCS args + ) + + # 6. Prepare Batch Request (2 items to trigger concurrency) + batch_dto = BatchExecutionRequestDto( + items=[ + BatchExecutionItemDto(row_index=0, args={"param": "val1"}), + BatchExecutionItemDto(row_index=1, args={"param": "val2"}), + ] + ) + + # 7. Execute + # We expect this to fail because ConcurrencyDetectingSession will raise RuntimeError + # when the second task tries to call execute/commit/refresh while the first is sleeping. + + # Actually, batch_execute_workflow catches exceptions inside process_row and returns them in results. + # So we should check the results for failures. + response = await service.batch_execute_workflow( + workflow_id="id-123", + batch_dto=batch_dto, + user=sample_user, + ) + + assert response is not None + assert len(response.results) == 2 + + # If concurrency issue exists, at least one of them should have failed with our RuntimeError + failed_results = [r for r in response.results if r.status == "FAILED"] + + print("\nBatch execution results:") + for r in response.results: + print(f"Row {r.row_index}: {r.status} (Error: {r.error})") + + assert len(failed_results) > 0, "Expected at least one failure due to concurrency" + assert any("CONCURRENCY DETECTED" in str(r.error) for r in failed_results), "Expected concurrency error in results" diff --git a/backend/tests/workflows/test_workflow_controller_blocking.py b/backend/tests/workflows/test_workflow_controller_blocking.py new file mode 100644 index 00000000..259945c6 --- /dev/null +++ b/backend/tests/workflows/test_workflow_controller_blocking.py @@ -0,0 +1,95 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from src.auth.auth_guard import get_current_user +from src.users.user_model import UserModel, UserRoleEnum +from src.workflows.workflow_controller import router +from src.workflows.workflow_service import WorkflowService + + +@pytest.fixture(name="mock_user") +def fixture_mock_user(): + return UserModel( + id=1, + email="test@example.com", + name="Test User", + roles=[UserRoleEnum.WORKFLOWS], + ) + + +@pytest.fixture(name="mock_service") +def fixture_mock_service(): + service = AsyncMock() + service.get_workflow = AsyncMock() + # Synchronous method in service that we will mock to block + service.list_executions = MagicMock() + return service + + +@pytest.fixture(name="app") +def fixture_app(mock_user, mock_service): + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_current_user] = lambda: mock_user + app.dependency_overrides[WorkflowService] = lambda: mock_service + return app + + +@pytest.mark.anyio +async def test_list_executions_endpoint_blocks_event_loop(app, mock_service): + mock_workflow = MagicMock() + mock_workflow.user_id = 1 + mock_service.get_workflow.return_value = mock_workflow + + # Mock list_executions to block + def blocking_list_executions(*args, **kwargs): + time.sleep(0.2) # Blocking sleep + return [{"execution_id": "exec1"}] + + mock_service.list_executions.side_effect = blocking_list_executions + + # Background task to check for interleaving + bg_runs = [] + async def background_task(): + for _ in range(4): + await asyncio.sleep(0.05) + bg_runs.append(time.time()) + + # We need to run the client and the background task in the same event loop. + # httpx.AsyncClient with ASGITransport runs the app in the same event loop. + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: + bg_promise = asyncio.create_task(background_task()) + + start_time = time.time() + # This request will trigger the blocking list_executions + response = await ac.get("/api/workflows/wf1/executions") + request_end = time.time() + + await bg_promise + + assert response.status_code == 200 + assert len(response.json()) == 1 + + # If blocked, all bg runs should be after request_end + blocked_runs = [t for t in bg_runs if t > request_end] + + assert len(blocked_runs) == len(bg_runs), "Expected event loop to be blocked during list_executions request" diff --git a/backend/tests/workflows/test_workflow_repository.py b/backend/tests/workflows/test_workflow_repository.py new file mode 100644 index 00000000..f44b8387 --- /dev/null +++ b/backend/tests/workflows/test_workflow_repository.py @@ -0,0 +1,101 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for Workflow Repository.""" + +import datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.workflows.dto.workflow_search_dto import WorkflowSearchDto +from src.workflows.repository.workflow_repository import WorkflowRepository +from src.workflows.schema.workflow_model import Workflow + + +def get_dummy_workflow(**kwargs): + now = datetime.datetime.now(datetime.UTC) + defaults = { + "id": "wf-123", + "user_id": 1, + "name": "Test Workflow", + "description": "A test workflow", + "steps": [ + { + "step_id": "step1", + "type": "user_input", + "inputs": {}, + "settings": {}, + } + ], + "created_at": now, + "updated_at": now, + } + defaults.update(kwargs) + return Workflow(**defaults) + + +@pytest.mark.anyio +async def test_query_no_filters(): + mock_db = AsyncMock() + + mock_count_result = MagicMock() + mock_count_result.scalar_one.return_value = 3 + + mock_workflows_result = MagicMock() + mock_wf = get_dummy_workflow(id="wf-1") + mock_workflows_result.scalars().all.return_value = [mock_wf] + + mock_db.execute.side_effect = [mock_count_result, mock_workflows_result] + + repo = WorkflowRepository(db=mock_db) + search_dto = WorkflowSearchDto(limit=10, offset=0) + response = await repo.query(user_id=1, search_dto=search_dto) + + assert response is not None + assert response.count == 3 + assert response.page == 1 + assert response.page_size == 10 + assert response.total_pages == 1 + assert len(response.data) == 1 + assert response.data[0].id == "wf-1" + + assert mock_db.execute.call_count == 2 + + +@pytest.mark.anyio +async def test_query_with_name_filter(): + mock_db = AsyncMock() + + mock_count_result = MagicMock() + mock_count_result.scalar_one.return_value = 1 + + mock_workflows_result = MagicMock() + mock_wf = get_dummy_workflow(id="wf-2", name="Specific Name") + mock_workflows_result.scalars().all.return_value = [mock_wf] + + mock_db.execute.side_effect = [mock_count_result, mock_workflows_result] + + repo = WorkflowRepository(db=mock_db) + search_dto = WorkflowSearchDto(name="Specific", limit=5, offset=0) + response = await repo.query(user_id=1, search_dto=search_dto) + + assert response is not None + assert response.count == 1 + assert response.page == 1 + assert response.page_size == 5 + assert response.total_pages == 1 + assert len(response.data) == 1 + assert response.data[0].name == "Specific Name" + + assert mock_db.execute.call_count == 2 diff --git a/backend/tests/workflows/test_workflow_service.py b/backend/tests/workflows/test_workflow_service.py index af491a5c..45737af6 100644 --- a/backend/tests/workflows/test_workflow_service.py +++ b/backend/tests/workflows/test_workflow_service.py @@ -262,6 +262,52 @@ async def test_execute_workflow_success( mock_exec_client.create_execution.assert_called_once() mock_run_repo.create.assert_called_once() + @pytest.mark.anyio + @patch("src.workflows.workflow_service.executions_v1.ExecutionsAsyncClient") + async def test_execute_workflow_invalid_workspace_id_silently_ignored( + self, + mock_exec_client_class, + workflow_service, + mock_workflow_repo, + mock_run_repo, + sample_workflow_model, + sample_user, + ): + # Setup + workflow_service.get_by_id = AsyncMock( + return_value=sample_workflow_model + ) + + # Mock GCP Execution Client + mock_exec_client = AsyncMock() + mock_exec_client_class.return_value = mock_exec_client + + mock_response = MagicMock() + mock_response.name = ( + "projects/p/locations/l/workflows/w/executions/exec-123" + ) + mock_exec_client.create_execution = AsyncMock( + return_value=mock_response + ) + + # Pass invalid workspace_id + args = {"workspace_id": "invalid"} + + # Execute + exec_id = await workflow_service.execute_workflow( + workflow_id="id-123", + args=args, + user=sample_user, + ) + + assert exec_id == "exec-123" + workflow_service.get_by_id.assert_called_once_with("id-123") + mock_exec_client.create_execution.assert_called_once() + + mock_run_repo.create.assert_called_once() + called_args = mock_run_repo.create.call_args[0][0] + assert called_args.workspace_id is None + class TestGetExecutionDetails: """Tests for get_execution_details method.""" diff --git a/backend/tests/workflows/test_workflow_service_blocking.py b/backend/tests/workflows/test_workflow_service_blocking.py new file mode 100644 index 00000000..263c4b49 --- /dev/null +++ b/backend/tests/workflows/test_workflow_service_blocking.py @@ -0,0 +1,161 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from google.cloud.workflows import executions_v1 as exec_v1 + +from src.workflows.schema.workflow_model import ( + GenerateTextInputs, + GenerateTextSettings, + GenerateTextStep, + NodeTypes, + WorkflowModel, +) +from src.workflows.schema.workflow_run_model import ( + WorkflowRunStatusEnum, +) +from src.workflows.workflow_service import WorkflowService + + +@pytest.fixture(name="mock_workflow_repo") +def fixture_mock_workflow_repo(): + repo = AsyncMock() + return repo + + +@pytest.fixture(name="mock_run_repo") +def fixture_mock_run_repo(): + repo = AsyncMock() + return repo + + +@pytest.fixture(name="workflow_service") +def fixture_workflow_service(mock_workflow_repo, mock_run_repo): + return WorkflowService( + workflow_repository=mock_workflow_repo, + workflow_run_repository=mock_run_repo, + source_asset_service=MagicMock(), + ) + + +@pytest.fixture(name="sample_workflow_model") +def fixture_sample_workflow_model(): + return WorkflowModel( + id="id-1234", + user_id=1, + name="Test Workflow", + description="A test workflow", + steps=[ + GenerateTextStep( + step_id="step_1", + type=NodeTypes.GENERATE_TEXT, + inputs=GenerateTextInputs(prompt="Hello World"), + settings=GenerateTextSettings( + model="gemini-1.5", temperature=0.7 + ), + ), + ], + ) + + +@pytest.mark.anyio +@patch("src.workflows.workflow_service.executions_v1.ExecutionsClient") +@patch("src.workflows.workflow_service.google.auth.default") +@patch("src.workflows.workflow_service.AuthorizedSession") +async def test_get_execution_details_blocks_event_loop( + mock_auth_session_class, + mock_auth_default, + mock_exec_client_class, + workflow_service, + mock_run_repo, + sample_workflow_model, +): + # Mock ExecutionsClient to block on get_execution + mock_client = MagicMock() + mock_exec_client_class.return_value = mock_client + + def blocking_get_execution(*args, **kwargs): + time.sleep(0.2) # Blocking sleep + mock_execution = MagicMock() + mock_execution.name = ( + "projects/p/locations/l/workflows/w/executions/e-123" + ) + mock_execution.state = exec_v1.Execution.State.SUCCEEDED + mock_execution.argument = '{"arg1": "val1"}' + mock_execution.result = '{"res1": "val1"}' + mock_execution.start_time = MagicMock() + mock_execution.end_time = MagicMock() + return mock_execution + + mock_client.get_execution.side_effect = blocking_get_execution + + # Mock Auth for REST API + mock_auth_default.return_value = (MagicMock(), "project-id") + mock_session = MagicMock() + mock_auth_session_class.return_value = mock_session + + def blocking_session_get(*args, **kwargs): + time.sleep(0.2) # Blocking sleep + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "stepEntries": [{"step": "step_1", "state": "STATE_SUCCEEDED"}], + } + return mock_response + + mock_session.get.side_effect = blocking_session_get + + # Mock DB Snapshot (no block) + mock_run = MagicMock() + mock_run.id = "e-123" + mock_run.workflow_snapshot = sample_workflow_model.model_dump( + mode="json" + ) + mock_run.status = WorkflowRunStatusEnum.RUNNING.value + mock_run_repo.get_by_id.return_value = mock_run + + # Setup mock get_by_id in service fallback + workflow_service.get_by_id = AsyncMock( + return_value=sample_workflow_model + ) + + # Background task to check for interleaving + bg_runs = [] + async def background_task(): + for _ in range(8): + await asyncio.sleep(0.05) + bg_runs.append(time.time()) + + bg_promise = asyncio.create_task(background_task()) + + start_time = time.time() + # This should block for ~0.4s total (0.2s in get_execution, 0.2s in session.get) + details = await workflow_service.get_execution_details( + workflow_id="id-123", + execution_id="e-123", + ) + get_details_end = time.time() + + await bg_promise + + assert details is not None + + # If blocked, all bg runs should be after get_details_end + blocked_runs = [t for t in bg_runs if t > get_details_end] + + assert len(blocked_runs) == len(bg_runs), "Expected event loop to be blocked during get_execution_details" diff --git a/backend/tests/workflows_executor/test_event_loop_blocking.py b/backend/tests/workflows_executor/test_event_loop_blocking.py new file mode 100644 index 00000000..a6018811 --- /dev/null +++ b/backend/tests/workflows_executor/test_event_loop_blocking.py @@ -0,0 +1,137 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.workflows_executor.workflows_executor_service import ( + WorkflowsExecutorService, +) + + +@pytest.fixture(name="service") +def fixture_service(): + with ( + patch( + "src.workflows_executor.workflows_executor_service.RestClient", + ) as mock_rest_client_class, + patch( + "src.workflows_executor.workflows_executor_service.GenAIModelSetup.init", + ) as mock_genai_init, + ): + mock_rest_client = AsyncMock() + mock_rest_client_class.return_value = mock_rest_client + + mock_genai_client = MagicMock() + mock_genai_init.return_value = mock_genai_client + + service = WorkflowsExecutorService() + service.mock_rest_client = mock_rest_client + service.mock_genai_client = mock_genai_client + yield service + + +@pytest.mark.anyio +async def test_generate_text_blocks_event_loop(service): + # Create request mock DTO + request = MagicMock() + request.config.temperature = 0.7 + request.config.model = "gemini-1.5-pro" + request.inputs.prompt = "Write a story" + request.inputs.input_images = None + request.inputs.input_videos = None + + # Mock chunk generator to block the thread (simulating slow I/O in sync call) + def blocking_generator(*args, **kwargs): + # First chunk + time.sleep(0.2) # Blocking sleep + mock_chunk = MagicMock() + mock_chunk.text = "Hello " + yield mock_chunk + + # Second chunk + time.sleep(0.2) # Blocking sleep + mock_chunk = MagicMock() + mock_chunk.text = "World!" + yield mock_chunk + + service.mock_genai_client.models.generate_content_stream.side_effect = ( + blocking_generator + ) + + # Background task that should run concurrently if loop is not blocked + background_task_executed = 0 + + async def background_task(): + nonlocal background_task_executed + for _ in range(5): + await asyncio.sleep(0.05) + background_task_executed += 1 + + # Run them "concurrently" + bg_promise = asyncio.create_task(background_task()) + + # This call is expected to block the event loop because it's sync under the hood + result = await service.generate_text(request) + + await bg_promise + + assert result["generated_text"] == "Hello World!" + + # If it blocked, the background task (which needs 0.25s total, but yields every 0.05s) + # won't have had a chance to run intermediate steps during the 0.4s blocking of generate_text. + # Actually, because we await generate_text, if it blocks, it blocks the whole loop. + # The background task will only run AFTER generate_text completes. + # If it was non-blocking (e.g. using to_thread), the background task would interleave. + # + # We can detect blocking by checking if the background task was able to run *during* + # the execution of generate_text. + # If we measure time, they both run. But we want to see if they interleaved. + # A simple way to check if it blocked: + # If it did NOT block, background_task_executed should be > 0 before generate_text finished? + # Wait, we can't easily check that without instrumenting generate_text. + # + # Alternatively, we can check if the total time is ~ 0.4s (sequential) vs ~0.4s (concurrent). + # Since background task is 0.25s, if concurrent it should take max(0.4, 0.25) = 0.4s. + # If blocking, it takes 0.4 (generate_text) + 0.25 (bg_task) = 0.65s. + + # Let's verify interleaving by having the bg task record timestamps. + bg_runs = [] + async def background_task_with_timestamps(): + for _ in range(4): + await asyncio.sleep(0.05) + bg_runs.append(time.time()) + + bg_promise = asyncio.create_task(background_task_with_timestamps()) + + start_time = time.time() + await service.generate_text(request) + generate_text_end = time.time() + + await bg_promise + end_time = time.time() + + # If blocked, all bg_runs timestamps will be AFTER generate_text_end + # (or very close to it, allowing for scheduler latency) + blocked_runs = [t for t in bg_runs if t > generate_text_end] + + # Since generate_text takes 0.4s, and bg task runs every 0.05s, + # if it was non-blocking, we should have runs at 0.05, 0.10, 0.15, 0.20 (before 0.4s end). + # So blocked_runs should be empty or small if non-blocking. + # If blocked, ALL runs will be after generate_text_end. + + assert len(blocked_runs) == len(bg_runs), "Expected event loop to be blocked, so all background tasks run after generate_text" diff --git a/backend/uv.lock b/backend/uv.lock index a0f71779..03e3faa3 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -612,6 +612,7 @@ dependencies = [ { name = "alembic" }, { name = "asyncpg" }, { name = "cloud-sql-python-connector", extra = ["asyncpg"] }, + { name = "cryptography" }, { name = "fastapi" }, { name = "fastapi-utilities" }, { name = "firebase-admin" }, @@ -634,6 +635,7 @@ dependencies = [ { name = "pandas" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-settings" }, + { name = "pyjwt" }, { name = "pypdf" }, { name = "python-multipart" }, { name = "pyyaml" }, @@ -665,6 +667,7 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.29.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=25.1.0" }, { name = "cloud-sql-python-connector", extras = ["asyncpg"], specifier = ">=1.0.0" }, + { name = "cryptography", specifier = ">=42.0.0" }, { name = "fastapi", specifier = ">=0.118.0" }, { name = "fastapi-utilities", specifier = "==0.3.0" }, { name = "firebase-admin", specifier = ">=6.9.0" }, @@ -686,6 +689,7 @@ requires-dist = [ { name = "pandas", specifier = ">=2.3.3" }, { name = "pydantic", extras = ["email"], specifier = ">=2.11.10" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, + { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pypdf", specifier = ">=6.1.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.5" }, diff --git a/bootstrap.sh b/bootstrap.sh old mode 100644 new mode 100755 index ce088db8..58f826ca --- a/bootstrap.sh +++ b/bootstrap.sh @@ -213,14 +213,15 @@ check_prerequisites() { else fail "Please install jq and run this script again."; fi fi + export PATH="$PWD/node_modules/.bin:$PATH" if ! command -v firebase &> /dev/null; then warn "Firebase CLI ('firebase-tools') is not installed. It is required for automation." prompt "Would you like to try and install it now via npm? (y/n)"; read -r REPLY < /dev/tty if [[ $REPLY =~ ^[Yy]$ ]]; then if ! command -v npm &> /dev/null; then fail "npm is required to install firebase-tools. Please install Node.js and npm first."; fi - info "Installing firebase-tools globally..."; sudo npm install -g firebase-tools + info "Installing firebase-tools locally..."; npm install firebase-tools else - fail "Please install firebase-tools (npm install -g firebase-tools) and run this script again." + fail "Please install firebase-tools (npm install firebase-tools) and run this script again." fi fi check_and_install_uv @@ -442,13 +443,53 @@ configure_environment() { prompt "Please provide the following value:" prompt_and_update_tfvar "GitHub Branch to deploy from" "$DEFAULT_BRANCH_NAME" "github_branch_name" "GITHUB_BRANCH" + # --- Authentication Configuration --- + prompt "Choose Authentication Provider:" + echo " (1) Google Auth (default)" + echo " (2) Microsoft Entra ID" + read -p " Enter choice [1]: " AUTH_CHOICE < /dev/tty + AUTH_CHOICE=${AUTH_CHOICE:-1} + + if [ "$AUTH_CHOICE" -eq 2 ]; then + prompt "Please configure Microsoft Entra ID:" + read -p " Entra Client ID: " ENTRA_CLIENT_ID < /dev/tty + read -p " Entra Tenant ID: " ENTRA_TENANT_ID < /dev/tty + read -s -p " Entra Client Secret: " ENTRA_CLIENT_SECRET < /dev/tty; echo + read -p " Domain Name (or IP): " DOMAIN_NAME < /dev/tty + read -p " GCP Organization ID: " ORG_ID < /dev/tty + + sed -i.bak "s|^[#[:space:]]*entra_client_id[[:space:]]*=.*|entra_client_id = \"$ENTRA_CLIENT_ID\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*entra_tenant_id[[:space:]]*=.*|entra_tenant_id = \"$ENTRA_TENANT_ID\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*entra_client_secret[[:space:]]*=.*|entra_client_secret = \"$ENTRA_CLIENT_SECRET\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*domain_name[[:space:]]*=.*|domain_name = \"$DOMAIN_NAME\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*org_id[[:space:]]*=.*|org_id = \"$ORG_ID\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*workforce_pool_id[[:space:]]*=.*|workforce_pool_id = \"cs-workforce-pool\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*iap_access_members[[:space:]]*=.*|iap_access_members = [\"principalSet://iam.googleapis.com/locations/global/workforcePools/cs-workforce-pool/*\"]|g" "$TFVARS_FILE_PATH" + + write_state "AUTH_CHOICE" "2" + write_state "AUTO_ENTRA_CLIENT_SECRET" "$ENTRA_CLIENT_SECRET" + else + # Google Auth selected + # Ensure Entra variables in .tfvars are set to empty or default + sed -i.bak "s|^[#[:space:]]*entra_client_id[[:space:]]*=.*|entra_client_id = \"\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*entra_tenant_id[[:space:]]*=.*|entra_tenant_id = \"\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*entra_client_secret[[:space:]]*=.*|entra_client_secret = \"\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*domain_name[[:space:]]*=.*|domain_name = \"\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*org_id[[:space:]]*=.*|org_id = \"\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*workforce_pool_id[[:space:]]*=.*|workforce_pool_id = \"\"|g" "$TFVARS_FILE_PATH" + sed -i.bak "s|^[#[:space:]]*iap_access_members[[:space:]]*=.*|iap_access_members = []|g" "$TFVARS_FILE_PATH" + + write_state "AUTH_CHOICE" "1" + fi + rm -f "$TFVARS_FILE_PATH.bak" + write_state "ENV_NAME" "$ENV_NAME"; write_state "BE_SERVICE_NAME" "$BE_SERVICE_NAME"; write_state "FE_SERVICE_NAME" "$FE_SERVICE_NAME"; write_state "GITHUB_BRANCH" "$GITHUB_BRANCH" else info "Environment directory '$ENV_DIR' already configured."; fi success "Configuration files for '$ENV_NAME' environment are ready." } handle_manual_steps() { - step 6 "Manual Steps Required"; cd "$REPO_ROOT/infra"; TFVARS_FILE_PATH="$ENV_DIR/$ENV_NAME.tfvars" + step 6 "Manual Steps Required"; cd "$REPO_ROOT/infra"; TFVARS_FILE_PATH="$REPO_ROOT/infra/$ENV_DIR/$ENV_NAME.tfvars" info "Enabling required Google Cloud APIs..."; gcloud services enable cloudbuild.googleapis.com secretmanager.googleapis.com firebase.googleapis.com iap.googleapis.com identitytoolkit.googleapis.com texttospeech.googleapis.com workflows.googleapis.com --project="$GCP_PROJECT_ID" if [ -z "$GITHUB_CONN_NAME" ]; then prompt "\nDo you already have a Cloud Build Host Connection for GitHub in this project? (y/n)"; read -r REPLY < /dev/tty @@ -473,7 +514,10 @@ handle_manual_steps() { # --- Automate .tfvars placeholder replacement --- info "\nConfiguring OAuth Client ID and Project ID in .tfvars file..." - if [ -z "$AUTO_OAUTH_CLIENT_ID" ]; then + if grep -q 'entra_client_id.*="[^"]' "$TFVARS_FILE_PATH"; then + info "Entra Auth detected in .tfvars. Skipping Google OAuth Client ID prompt." + AUTO_OAUTH_CLIENT_ID="not-used-for-entra" + elif [ -z "$AUTO_OAUTH_CLIENT_ID" ]; then warn "The OAuth Client ID is required for the .tfvars file." echo "1. Open this URL in your browser to find your OAuth Client ID:" echo -e " ${C_YELLOW}https://console.cloud.google.com/apis/credentials?project=${GCP_PROJECT_ID}${C_RESET}" @@ -534,7 +578,10 @@ populate_oauth_secrets() { # The client ID is the one NOT associated with the API key. AUTO_OAUTH_CLIENT_ID=$(echo "$API_RESPONSE" | jq -r '.oauthClientId') - if [ -z "$AUTO_OAUTH_CLIENT_ID" ] || [ "$AUTO_OAUTH_CLIENT_ID" == "null" ]; then + if grep -q 'entra_client_id.*="[^"]' "$TFVARS_FILE_PATH"; then + info "Entra Auth detected. Setting dummy OAuth Client ID." + AUTO_OAUTH_CLIENT_ID="not-used-for-entra" + elif [ -z "$AUTO_OAUTH_CLIENT_ID" ] || [ "$AUTO_OAUTH_CLIENT_ID" == "null" ]; then warn "Could not automatically find the OAuth Client ID via API." info "Please perform the following manual steps:" echo "1. Open this URL in your browser to find your OAuth Client ID:" @@ -560,6 +607,29 @@ populate_oauth_secrets() { success "Audiences updated in .tfvars file." } +setup_entra_secrets() { + step 11 "Automating Entra ID Secret Population" + local ENV_DIR="environments/$ENV_NAME" + local TFVARS_FILE_PATH="$REPO_ROOT/infra/$ENV_DIR/$ENV_NAME.tfvars" + + if grep -q 'entra_client_id.*="[^"]' "$TFVARS_FILE_PATH"; then + if [ -n "$AUTO_ENTRA_CLIENT_SECRET" ]; then + local SECRET_NAME="entra-client-secret" + info "Ensuring Secret Manager secret '$SECRET_NAME' exists..." + if ! gcloud secrets describe "$SECRET_NAME" --project="$GCP_PROJECT_ID" >/dev/null 2>&1; then + gcloud secrets create "$SECRET_NAME" --replication-policy="automatic" --project="$GCP_PROJECT_ID" + fi + info "Populating '$SECRET_NAME' with value..." + echo -n "$AUTO_ENTRA_CLIENT_SECRET" | gcloud secrets versions add "$SECRET_NAME" --data-file="-" --project="$GCP_PROJECT_ID" --quiet + success "Secret '$SECRET_NAME' has been populated." + else + warn "Entra Client Secret is not set in environment or state. Skipping secret creation." + fi + else + info "Entra Auth not selected. Skipping Entra secret population." + fi +} + setup_db_secrets() { step 9 "Configuring Database Secrets" # Renumber subsequent steps @@ -594,7 +664,7 @@ setup_db_secrets() { run_terraform() { step 10 "Deploying Infrastructure with Terraform"; TFVARS_FILE_PATH="$REPO_ROOT/infra/environments/$ENV_NAME/$ENV_NAME.tfvars"; info "Navigating to $REPO_ROOT/infra/environments/$ENV_NAME..."; cd "$REPO_ROOT/infra/environments/$ENV_NAME" - info "Initializing Terraform..."; terraform init -reconfigure + info "Initializing Terraform..."; terraform init -migrate-state -force-copy info "Planning Terraform changes..."; terraform plan -var-file="$TFVARS_FILE_PATH" prompt "\nTerraform is ready to apply the changes. This will create the infrastructure, including empty secret shells."; prompt "Do you want to proceed with 'terraform apply'? (y/n)"; read -r REPLY < /dev/tty if [[ ! $REPLY =~ ^[Yy]$ ]]; then warn "Apply cancelled."; return; fi @@ -603,7 +673,10 @@ run_terraform() { update_oauth_client() { step 11 "Configuring OAuth Client URIs"; cd "$REPO_ROOT" - if [ -z "$AUTO_OAUTH_CLIENT_ID" ]; then warn "Could not find OAuth Client ID automatically. Skipping URI update."; return; fi + if [ -z "$AUTO_OAUTH_CLIENT_ID" ] || [ "$AUTO_OAUTH_CLIENT_ID" == "not-used-for-entra" ]; then + info "OAuth Client ID not used/configured. Skipping OAuth Client URI update." + return + fi info "Fetching full OAuth client name..."; local OAUTH_CLIENT_FULL_NAME=$(gcloud iap oauth-clients list "$GCP_PROJECT_ID" --format="json" | jq -r --arg clientid "$AUTO_OAUTH_CLIENT_ID" '.[] | select(.name | contains($clientid)) | .name') if [ -z "$OAUTH_CLIENT_FULL_NAME" ]; then warn "Could not resolve the full name for the OAuth client. Skipping URI update."; return; fi info "Ensuring OAuth Client has all required origins and redirect URIs..."; local PROJECT_DOMAIN_BASE=$(gcloud projects describe "$GCP_PROJECT_ID" --format='value(projectId)') @@ -791,6 +864,7 @@ main() { "setup_db_secrets" "run_terraform" "populate_oauth_secrets" + "setup_entra_secrets" "update_oauth_client" "update_secrets" "seed_data" @@ -807,7 +881,7 @@ main() { fi done - step 14 "🎉 Deployment Complete! 🎉"; + step 15 "🎉 Deployment Complete! 🎉"; info "Fetching your application URLs..."; cd "$REPO_ROOT/infra/environments/$ENV_NAME" diff --git a/frontend/.gitignore b/frontend/.gitignore index bbe6713a..c8396d9e 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -4,6 +4,8 @@ *environment.* !environment.ts !environment.prod.ts +!environment.development.ts.example +!environment.test.ts.example proxy.conf.json # Compiled output diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 28469596..b1b5c418 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -18,7 +18,7 @@ WORKDIR /app COPY . /app/ RUN npm ci -RUN npm run build-dev +RUN npx ng build --configuration=production FROM nginx:alpine COPY --from=builder /app/dist/creative-studio/browser /usr/share/nginx/html diff --git a/frontend/README.md b/frontend/README.md index 8c8c6666..8ed68989 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -6,7 +6,7 @@ To run the frontend locally using Docker Compose, you need to configure the envi ### 1. Configure `development.environment.ts` -Copy from `environments/environment.ts` and create a new file `environments/environment.development.ts`. +Copy from `environments/environment.development.ts.example` and create a new file `environments/environment.development.ts`. Add the following configuration (replace values with your specific configuration): ```typescript diff --git a/frontend/cloudbuild-deploy.yaml b/frontend/cloudbuild-deploy.yaml index 01ecbbcc..206e2085 100644 --- a/frontend/cloudbuild-deploy.yaml +++ b/frontend/cloudbuild-deploy.yaml @@ -30,6 +30,8 @@ steps: # Inject into Angular app (environment.prod.ts) sed -i "s|BACKEND_URL_PLACEHOLDER|$${_BACKEND_URL}/api|g" src/environments/environment.prod.ts sed -i "s|GOOGLE_CLIENT_ID_PLACEHOLDER|$${GOOGLE_CLIENT_ID}|g" src/environments/environment.prod.ts + sed -i "s|ENTRA_CLIENT_ID_PLACEHOLDER|$${_ENTRA_CLIENT_ID}|g" src/environments/environment.prod.ts + sed -i "s|ENTRA_TENANT_ID_PLACEHOLDER|$${_ENTRA_TENANT_ID}|g" src/environments/environment.prod.ts # Inject Firebase config sed -i "s|FIREBASE_API_KEY_PLACEHOLDER|$${FIREBASE_API_KEY}|g" src/environments/environment.prod.ts sed -i "s|FIREBASE_AUTH_DOMAIN_PLACEHOLDER|$${FIREBASE_AUTH_DOMAIN}|g" src/environments/environment.prod.ts @@ -48,6 +50,8 @@ steps: - '_FE_SERVICE_NAME=${_FE_SERVICE_NAME}' - '_BACKEND_SERVICE_ID=${_BACKEND_SERVICE_ID}' - '_FIREBASE_SITE_ID=${_FIREBASE_SITE_ID}' + - '_ENTRA_CLIENT_ID=${_ENTRA_CLIENT_ID}' + - '_ENTRA_TENANT_ID=${_ENTRA_TENANT_ID}' secretEnv: - FIREBASE_API_KEY - FIREBASE_AUTH_DOMAIN @@ -106,3 +110,11 @@ availableSecrets: options: logging: CLOUD_LOGGING_ONLY + +substitutions: + _FE_SERVICE_NAME: cstudio-fe + _BACKEND_SERVICE_ID: cstudio-be + _BACKEND_URL: https://gcpvto4.web.app + _FIREBASE_SITE_ID: gcpvto4 + _ENTRA_CLIENT_ID: '6829b912-a054-4036-86a8-f5c3329cd41e' + _ENTRA_TENANT_ID: 'af731eba-c788-490b-b38f-41a97b164253' diff --git a/frontend/cloudbuild-run.yaml b/frontend/cloudbuild-run.yaml new file mode 100644 index 00000000..3d7b7ab8 --- /dev/null +++ b/frontend/cloudbuild-run.yaml @@ -0,0 +1,109 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +steps: + # Step 1: Inject secrets and variables into environment.prod.ts + - name: 'gcr.io/cloud-builders/gcloud' + id: 'Inject Environment Variables' + entrypoint: 'bash' + args: + - '-c' + - | + # Inject relative path for Backend URL (same-origin Load Balancer) + sed -i "s|BACKEND_URL_PLACEHOLDER|/api|g" src/environments/environment.prod.ts + + # Inject Firebase config secrets + sed -i "s|FIREBASE_API_KEY_PLACEHOLDER|$${FIREBASE_API_KEY}|g" src/environments/environment.prod.ts + sed -i "s|FIREBASE_AUTH_DOMAIN_PLACEHOLDER|$${FIREBASE_AUTH_DOMAIN}|g" src/environments/environment.prod.ts + sed -i "s|FIREBASE_PROJECT_ID_PLACEHOLDER|$${FIREBASE_PROJECT_ID}|g" src/environments/environment.prod.ts + sed -i "s|FIREBASE_STORAGE_BUCKET_PLACEHOLDER|$${FIREBASE_STORAGE_BUCKET}|g" src/environments/environment.prod.ts + sed -i "s|FIREBASE_SENDER_ID_PLACEHOLDER|$${FIREBASE_MESSAGING_SENDER_ID}|g" src/environments/environment.prod.ts + sed -i "s|FIREBASE_APP_ID_PLACEHOLDER|$${FIREBASE_APP_ID}|g" src/environments/environment.prod.ts + sed -i "s|FIREBASE_MEASUREMENT_ID_PLACEHOLDER|$${FIREBASE_MEASUREMENT_ID}|g" src/environments/environment.prod.ts + + # Set Google & Entra client IDs to empty (IAP handles identity) + sed -i "s|GOOGLE_CLIENT_ID_PLACEHOLDER||g" src/environments/environment.prod.ts + sed -i "s|ENTRA_CLIENT_ID_PLACEHOLDER||g" src/environments/environment.prod.ts + sed -i "s|ENTRA_TENANT_ID_PLACEHOLDER||g" src/environments/environment.prod.ts + dir: 'frontend' + secretEnv: + - FIREBASE_API_KEY + - FIREBASE_AUTH_DOMAIN + - FIREBASE_PROJECT_ID + - FIREBASE_STORAGE_BUCKET + - FIREBASE_MESSAGING_SENDER_ID + - FIREBASE_APP_ID + - FIREBASE_MEASUREMENT_ID + + # Step 2: Build the Docker image + - name: 'gcr.io/cloud-builders/docker' + id: 'Build Image' + env: ['DOCKER_BUILDKIT=1'] + args: + - 'build' + - '-t' + - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA' + - '.' + waitFor: ['Inject Environment Variables'] + dir: 'frontend' + + # Step 3: Push the Docker image + - name: 'gcr.io/cloud-builders/docker' + id: 'Push Image' + args: + - 'push' + - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA' + waitFor: ['Build Image'] + + # Step 4: Deploy to Cloud Run + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + id: 'Deploy' + entrypoint: 'gcloud' + args: + - 'run' + - 'deploy' + - '${_SERVICE_NAME}' + - '--image=${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA' + - '--region' + - '${_REGION}' + - '--quiet' + waitFor: ['Push Image'] + +images: + - '${_REGION}-docker.pkg.dev/$PROJECT_ID/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA' + +availableSecrets: + secretManager: + - versionName: projects/${PROJECT_ID}/secrets/FIREBASE_API_KEY/versions/latest + env: 'FIREBASE_API_KEY' + - versionName: projects/${PROJECT_ID}/secrets/FIREBASE_AUTH_DOMAIN/versions/latest + env: 'FIREBASE_AUTH_DOMAIN' + - versionName: projects/${PROJECT_ID}/secrets/FIREBASE_PROJECT_ID/versions/latest + env: 'FIREBASE_PROJECT_ID' + - versionName: projects/${PROJECT_ID}/secrets/FIREBASE_STORAGE_BUCKET/versions/latest + env: 'FIREBASE_STORAGE_BUCKET' + - versionName: projects/${PROJECT_ID}/secrets/FIREBASE_MESSAGING_SENDER_ID/versions/latest + env: 'FIREBASE_MESSAGING_SENDER_ID' + - versionName: projects/${PROJECT_ID}/secrets/FIREBASE_APP_ID/versions/latest + env: 'FIREBASE_APP_ID' + - versionName: projects/${PROJECT_ID}/secrets/FIREBASE_MEASUREMENT_ID/versions/latest + env: 'FIREBASE_MEASUREMENT_ID' + +substitutions: + _SERVICE_NAME: 'cstudio-fe' + _REPO_NAME: 'cs-fe-development-repo' + _REGION: 'us-central1' + +options: + logging: CLOUD_LOGGING_ONLY diff --git a/frontend/firebase.json b/frontend/firebase.json index b2774f22..9339de8b 100644 --- a/frontend/firebase.json +++ b/frontend/firebase.json @@ -21,6 +21,15 @@ } ], "headers": [ + { + "source": "/index.html", + "headers": [ + { + "key": "Cache-Control", + "value": "no-cache, no-store, must-revalidate" + } + ] + }, { "source": "/api/**", "headers": [ diff --git a/frontend/manual_deploy.sh b/frontend/manual_deploy.sh new file mode 100755 index 00000000..baa1a6fd --- /dev/null +++ b/frontend/manual_deploy.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -e + +export FIREBASE_API_KEY=$(gcloud secrets versions access latest --secret="FIREBASE_API_KEY" --project="gcpvto4") +export FIREBASE_AUTH_DOMAIN=$(gcloud secrets versions access latest --secret="FIREBASE_AUTH_DOMAIN" --project="gcpvto4") +export FIREBASE_PROJECT_ID=$(gcloud secrets versions access latest --secret="FIREBASE_PROJECT_ID" --project="gcpvto4") +export FIREBASE_STORAGE_BUCKET=$(gcloud secrets versions access latest --secret="FIREBASE_STORAGE_BUCKET" --project="gcpvto4") +export FIREBASE_SENDER_ID=$(gcloud secrets versions access latest --secret="FIREBASE_SENDER_ID" --project="gcpvto4") +export FIREBASE_APP_ID=$(gcloud secrets versions access latest --secret="FIREBASE_APP_ID" --project="gcpvto4") +export FIREBASE_MEASUREMENT_ID=$(gcloud secrets versions access latest --secret="FIREBASE_MEASUREMENT_ID" --project="gcpvto4") +export GOOGLE_CLIENT_ID=$(gcloud secrets versions access latest --secret="GOOGLE_CLIENT_ID" --project="gcpvto4") +export ENTRA_CLIENT_ID=$(gcloud secrets versions access latest --secret="ENTRA_CLIENT_ID" --project="gcpvto4") +export ENTRA_TENANT_ID=$(gcloud secrets versions access latest --secret="ENTRA_TENANT_ID" --project="gcpvto4") +export BACKEND_URL="https://gcpvto4.web.app/api" + +# Make a backup of environment.prod.ts so we don't mess up the repo permanently +cp src/environments/environment.prod.ts src/environments/environment.prod.ts.bak + +sed -i "s|FIREBASE_API_KEY_PLACEHOLDER|$FIREBASE_API_KEY|g" src/environments/environment.prod.ts +sed -i "s|FIREBASE_AUTH_DOMAIN_PLACEHOLDER|$FIREBASE_AUTH_DOMAIN|g" src/environments/environment.prod.ts +sed -i "s|FIREBASE_PROJECT_ID_PLACEHOLDER|$FIREBASE_PROJECT_ID|g" src/environments/environment.prod.ts +sed -i "s|FIREBASE_STORAGE_BUCKET_PLACEHOLDER|$FIREBASE_STORAGE_BUCKET|g" src/environments/environment.prod.ts +sed -i "s|FIREBASE_SENDER_ID_PLACEHOLDER|$FIREBASE_SENDER_ID|g" src/environments/environment.prod.ts +sed -i "s|FIREBASE_APP_ID_PLACEHOLDER|$FIREBASE_APP_ID|g" src/environments/environment.prod.ts +sed -i "s|FIREBASE_MEASUREMENT_ID_PLACEHOLDER|$FIREBASE_MEASUREMENT_ID|g" src/environments/environment.prod.ts +sed -i "s|BACKEND_URL_PLACEHOLDER|$BACKEND_URL|g" src/environments/environment.prod.ts +sed -i "s|GOOGLE_CLIENT_ID_PLACEHOLDER|$GOOGLE_CLIENT_ID|g" src/environments/environment.prod.ts +sed -i "s|ENTRA_CLIENT_ID_PLACEHOLDER|$ENTRA_CLIENT_ID|g" src/environments/environment.prod.ts +sed -i "s|ENTRA_TENANT_ID_PLACEHOLDER|$ENTRA_TENANT_ID|g" src/environments/environment.prod.ts + +npm install +rm -rf .angular/cache +npm run build -- --configuration=production +cp firebase.json firebase.json.bak +sed -i "s|SITE_ID_PLACEHOLDER|gcpvto4|g" firebase.json +sed -i "s|BACKEND_SERVICE_ID_PLACEHOLDER|cstudio-be|g" firebase.json + +npx firebase deploy --only hosting --project gcpvto4 + +mv firebase.json.bak firebase.json + +# Restore the backup +mv src/environments/environment.prod.ts.bak src/environments/environment.prod.ts diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 2bdc311e..66345102 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,10 +1,18 @@ server { listen 8080; + port_in_redirect off; + absolute_redirect off; root /usr/share/nginx/html; index index.html index.htm; include /etc/nginx/mime.types; + # Force browser to always check with network before serving index.html + location = /index.html { + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + expires off; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bf82f544..7d4654c4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,8 @@ "@angular/platform-server": "^18.2.1", "@angular/router": "^18.2.1", "@angular/ssr": "^18.1.2", + "@azure/msal-angular": "^5.2.7", + "@azure/msal-browser": "^5.13.0", "d3": "^7.9.0", "express": "^4.18.2", "file-saver": "^2.0.5", @@ -1453,6 +1455,40 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/@azure/msal-angular": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@azure/msal-angular/-/msal-angular-5.2.7.tgz", + "integrity": "sha512-OaYAanVbZepr9Q19nzO7H5ZVjCYsAX2dDN2emUw+aUBoP+evyI6N+xvT4ucsZlSjy76hX6BT1/AmZbLW5qcmnw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@azure/msal-browser": "^5.13.0", + "rxjs": "^7.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.13.0.tgz", + "integrity": "sha512-Ea23x0U8XNFY+qJ9T44zO2BbY+AHdb+WdjmYnx36OhJ/KO+PGU5pmsNHf1DCElYX+6wyVRJz1HFeCPC/cHbRug==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.8.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.8.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.8.0.tgz", + "integrity": "sha512-5S4RHOcInL2Nu2U217tDZbWGI6StMfcWCrA7TWvWdJmXQ+cYrrIqr84AsN62fGh2MDBysiBJPt6CfWceJfloEA==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", @@ -3167,548 +3203,180 @@ "integrity": "sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==", "devOptional": true }, - "node_modules/@esbuild/aix-ppc64": { + "node_modules/@esbuild/linux-x64": { "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", - "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", + "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", "cpu": [ - "ppc64" + "x64" ], "dev": true, "optional": true, "os": [ - "aix" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", - "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", - "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=18" + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", - "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", - "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", - "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", - "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=18" + "node": ">= 4" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", - "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", - "cpu": [ - "x64" - ], + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", - "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", - "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", - "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], + "node_modules/@firebase/ai": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", + "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", + "peer": true, + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, "engines": { - "node": ">=18" + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", - "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "node_modules/@firebase/analytics": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", + "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", + "peer": true, + "dependencies": { + "@firebase/component": "0.6.18", + "@firebase/installations": "0.6.18", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", - "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", - "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", - "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", - "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", - "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", - "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", - "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", - "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", - "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", - "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", - "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", - "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@firebase/ai": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.1.tgz", - "integrity": "sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==", - "peer": true, - "dependencies": { - "@firebase/app-check-interop-types": "0.3.3", - "@firebase/component": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "@firebase/app": "0.x", - "@firebase/app-types": "0.x" - } - }, - "node_modules/@firebase/analytics": { - "version": "0.10.17", - "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.17.tgz", - "integrity": "sha512-n5vfBbvzduMou/2cqsnKrIes4auaBjdhg8QNA2ZQZ59QgtO2QiwBaXQZQE4O4sgB0Ds1tvLgUUkY+pwzu6/xEg==", - "peer": true, - "dependencies": { - "@firebase/component": "0.6.18", - "@firebase/installations": "0.6.18", - "@firebase/logger": "0.4.4", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app": "0.x" - } - }, - "node_modules/@firebase/analytics-compat": { - "version": "0.2.23", - "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", - "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", - "peer": true, - "dependencies": { - "@firebase/analytics": "0.10.17", - "@firebase/analytics-types": "0.8.3", - "@firebase/component": "0.6.18", - "@firebase/util": "1.12.1", - "tslib": "^2.1.0" - }, - "peerDependencies": { - "@firebase/app-compat": "0.x" + "node_modules/@firebase/analytics-compat": { + "version": "0.2.23", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.23.tgz", + "integrity": "sha512-3AdO10RN18G5AzREPoFgYhW6vWXr3u+OYQv6pl3CX6Fky8QRk0AHurZlY3Q1xkXO0TDxIsdhO3y65HF7PBOJDw==", + "peer": true, + "dependencies": { + "@firebase/analytics": "0.10.17", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.18", + "@firebase/util": "1.12.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" } }, "node_modules/@firebase/analytics-types": { @@ -5195,58 +4863,6 @@ "@inquirer/prompts": ">= 3 < 6" } }, - "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.0.13.tgz", - "integrity": "sha512-uiKPB0Fv6WEEOZjruu9a6wnW/8jrjzlZbxXscMB8kuCJ1k6kHpcBnuvaAWcqhbI7rqX5GKziwWEdD+wi2gNLfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.0.13.tgz", - "integrity": "sha512-bEVIIfK5mSQoG1R19qA+fJOvCB+0wVGGnXHT3smchBVahYBdlPn2OsZZKzlHWfb1E+PhLBmYfqB5zQXFP7hJig==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.0.13.tgz", - "integrity": "sha512-Yml1KlMzOnXj/tnW7yX8U78iAzTk39aILYvCPbqeewAq1kSzl+w59k/fiVkTBfvDi/oW/5YRxL+Fq+Y1Fr1r2Q==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.0.13.tgz", - "integrity": "sha512-afbVrsMgZ9dUTNUchFpj5VkmJRxvht/u335jUJ7o23YTbNbnpmXif3VKQGCtnjSh+CZaqm6N3CPG8KO3zwyZ1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@lmdb/lmdb-linux-x64": { "version": "3.0.13", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.0.13.tgz", @@ -5260,19 +4876,6 @@ "linux" ] }, - "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.0.13.tgz", - "integrity": "sha512-UCrMJQY/gJnOl3XgbWRZZUvGGBuKy6i0YNSptgMzHBjs+QYDYR1Mt/RLTOPy4fzzves65O1EDmlL//OzEqoLlA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@mermaid-js/parser": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", @@ -5282,58 +4885,6 @@ "langium": "3.3.1" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", @@ -5347,19 +4898,6 @@ "linux" ] }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@ngtools/webpack": { "version": "18.2.21", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.2.21.tgz", @@ -5771,165 +5309,22 @@ "node_modules/@protobufjs/inquire": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", - "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", - "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", - "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", - "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", - "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", - "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", - "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", - "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", - "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", - "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", - "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.22.4", @@ -5957,45 +5352,6 @@ "linux" ] }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", - "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", - "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", - "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@schematics/angular": { "version": "18.2.21", "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-18.2.21.tgz", @@ -11744,19 +11100,6 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -20279,1013 +19622,661 @@ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "devOptional": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true - }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", - "optional": true - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/to-regex-range/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/toxic": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toxic/-/toxic-1.0.1.tgz", - "integrity": "sha512-WI3rIGdcaKULYg7KVoB0zcjikqvcYYvcuT6D89bFPz2rVR0Rl0PK6x8/X62rtdLtBKIE985NzVf/auTtGegIIg==", - "devOptional": true, - "dependencies": { - "lodash": "^4.17.10" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/tree-dump": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "dev": true, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/trim-newlines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", - "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "optional": true, - "engines": { - "node": ">=6.10" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "devOptional": true, - "engines": { - "node": ">=0.6.x" - } - }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + "dependencies": { + "safe-buffer": "~5.1.0" } }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", "dev": true }, - "node_modules/tuf-js": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", - "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", - "dev": true, - "dependencies": { - "@tufjs/models": "2.0.1", - "debug": "^4.3.4", - "make-fetch-happen": "^13.0.1" - }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "optional": true + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "optional": true, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": ">=18" } }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dependencies": { - "prelude-ls": "^1.2.1" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { - "node": ">= 0.8.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "os-tmpdir": "~1.0.2" }, "engines": { - "node": ">= 0.6" + "node": ">=0.6.0" } }, - "node_modules/typed-assert": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", - "dev": true - }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "devOptional": true, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dependencies": { - "is-typedarray": "^1.0.0" - } - }, - "node_modules/typescript": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", - "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ua-parser-js": { - "version": "0.7.41", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", - "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "bin": { - "ua-parser-js": "script/cli.js" + "is-number": "^7.0.0" }, "engines": { - "node": "*" + "node": ">=8.0" } }, - "node_modules/ufo": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.2.tgz", - "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==", - "optional": true - }, - "node_modules/undici": { - "version": "6.19.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.7.tgz", - "integrity": "sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==", + "node_modules/to-regex-range/node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "engines": { - "node": ">=18.17" + "node": ">=0.12.0" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "engines": { - "node": ">=4" + "node": ">=0.6" } }, - "node_modules/unicode-emoji-modifier-base": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", - "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "node_modules/toxic": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toxic/-/toxic-1.0.1.tgz", + "integrity": "sha512-WI3rIGdcaKULYg7KVoB0zcjikqvcYYvcuT6D89bFPz2rVR0Rl0PK6x8/X62rtdLtBKIE985NzVf/auTtGegIIg==", "devOptional": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "dev": true, - "engines": { - "node": ">=4" + "lodash": "^4.17.10" } }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "dev": true, - "engines": { - "node": ">=4" - } + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "node_modules/tree-dump": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, "engines": { - "node": ">=18" + "node": ">=10.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unique-filename": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", - "dev": true, - "dependencies": { - "unique-slug": "^4.0.0" + "type": "github", + "url": "https://github.com/sponsors/streamich" }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "peerDependencies": { + "tslib": "2" } }, - "node_modules/unique-slug": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "bin": { + "tree-kill": "cli.js" } }, - "node_modules/unique-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", - "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", - "devOptional": true, - "dependencies": { - "crypto-random-string": "^2.0.0" - }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "dev": true, "engines": { "node": ">=8" } }, - "node_modules/universal-analytics": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", - "integrity": "sha512-HXSMyIcf2XTvwZ6ZZQLfxfViRm/yTGoRgDeTbojtq6rezeyKB0sTBcKH2fhddnteAHRcHiKgr/ACpbgjGOC6RQ==", - "devOptional": true, - "dependencies": { - "debug": "^4.3.1", - "uuid": "^8.0.0" - }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", "engines": { - "node": ">=12.18.2" + "node": ">= 14.0.0" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "optional": true, "engines": { - "node": ">= 4.0.0" + "node": ">=6.10" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "devOptional": true, "engines": { - "node": ">= 0.8" + "node": ">=0.6.x" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "tslib": "^1.8.1" }, - "bin": { - "update-browserslist-db": "cli.js" + "engines": { + "node": ">= 6" }, "peerDependencies": { - "browserslist": ">= 4.21.0" + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" } }, - "node_modules/update-notifier-cjs": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/update-notifier-cjs/-/update-notifier-cjs-5.1.7.tgz", - "integrity": "sha512-eZWTh8F+VCEoC4UIh0pKmh8h4izj65VvLhCpJpVefUxdYe0fU3GBrC4Sbh1AoWA/miNPAb6UVlp2fUQNsfp+3g==", - "devOptional": true, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, + "node_modules/tuf-js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-2.2.1.tgz", + "integrity": "sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==", + "dev": true, "dependencies": { - "boxen": "^5.0.0", - "chalk": "^4.1.0", - "configstore": "^5.0.1", - "has-yarn": "^2.1.0", - "import-lazy": "^2.1.0", - "is-ci": "^2.0.0", - "is-installed-globally": "^0.4.0", - "is-npm": "^5.0.0", - "is-yarn-global": "^0.3.0", - "isomorphic-fetch": "^3.0.0", - "pupa": "^2.1.1", - "registry-auth-token": "^5.0.1", - "registry-url": "^5.1.0", - "semver": "^7.3.7", - "semver-diff": "^3.1.1", - "xdg-basedir": "^4.0.0" + "@tufjs/models": "2.0.1", + "debug": "^4.3.4", + "make-fetch-happen": "^13.0.1" }, "engines": { - "node": ">=14" + "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, + "prelude-ls": "^1.2.1" + }, "engines": { - "node": ">=6" + "node": ">= 0.8.0" } }, - "node_modules/url-join": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", - "integrity": "sha512-H6dnQ/yPAAVzMQRvEvyz01hhfQL5qRWSEt7BX8t9DqnPw9BjMb64fjIRq76Uvf1hkHp+mTZvEVJ5guXOT0Xqaw==", - "devOptional": true - }, - "node_modules/url-template": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", - "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", - "devOptional": true - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "engines": { - "node": ">= 0.4.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "devOptional": true, - "bin": { - "uuid": "dist/bin/uuid" + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/valid-url": { + "node_modules/typed-assert": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", - "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==", - "devOptional": true + "resolved": "https://registry.npmjs.org/typed-assert/-/typed-assert-1.0.9.tgz", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "dev": true }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dev": true, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "devOptional": true, "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/validate-npm-package-name": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", - "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" + "is-typedarray": "^1.0.0" } }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" }, - "sugarss": { - "optional": true + { + "type": "paypal", + "url": "https://paypal.me/faisalman" }, - "terser": { - "optional": true + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" } + ], + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" } }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], + "node_modules/ufo": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.2.tgz", + "integrity": "sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==", + "optional": true + }, + "node_modules/undici": { + "version": "6.19.7", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.7.tgz", + "integrity": "sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==", "engines": { - "node": ">=12" + "node": ">=18.17" } }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "dev": true, - "optional": true, - "os": [ - "android" - ], "engines": { - "node": ">=12" + "node": ">=4" } }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "devOptional": true, "engines": { - "node": ">=12" + "node": ">=4" } }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "dev": true, - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, "engines": { - "node": ">=12" + "node": ">=4" } }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "dev": true, - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=12" + "node": ">=4" } }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "dev": true, - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=12" + "node": ">=4" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], "engines": { - "node": ">=12" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], + "node_modules/unique-filename": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", + "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", "dev": true, - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "unique-slug": "^4.0.0" + }, "engines": { - "node": ">=12" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], + "node_modules/unique-slug": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", + "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", "dev": true, - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "imurmurhash": "^0.1.4" + }, "engines": { - "node": ">=12" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "devOptional": true, + "dependencies": { + "crypto-random-string": "^2.0.0" + }, "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], + "node_modules/universal-analytics": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/universal-analytics/-/universal-analytics-0.5.3.tgz", + "integrity": "sha512-HXSMyIcf2XTvwZ6ZZQLfxfViRm/yTGoRgDeTbojtq6rezeyKB0sTBcKH2fhddnteAHRcHiKgr/ACpbgjGOC6RQ==", + "devOptional": true, + "dependencies": { + "debug": "^4.3.1", + "uuid": "^8.0.0" + }, "engines": { - "node": ">=12" + "node": ">=12.18.2" } }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "engines": { - "node": ">=12" + "node": ">= 4.0.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "engines": { - "node": ">=12" + "node": ">= 0.8" } }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], - "engines": { - "node": ">=12" + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], + "node_modules/update-notifier-cjs": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/update-notifier-cjs/-/update-notifier-cjs-5.1.7.tgz", + "integrity": "sha512-eZWTh8F+VCEoC4UIh0pKmh8h4izj65VvLhCpJpVefUxdYe0fU3GBrC4Sbh1AoWA/miNPAb6UVlp2fUQNsfp+3g==", + "devOptional": true, + "dependencies": { + "boxen": "^5.0.0", + "chalk": "^4.1.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.4.0", + "is-npm": "^5.0.0", + "is-yarn-global": "^0.3.0", + "isomorphic-fetch": "^3.0.0", + "pupa": "^2.1.1", + "registry-auth-token": "^5.0.1", + "registry-url": "^5.1.0", + "semver": "^7.3.7", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" + }, "engines": { - "node": ">=12" + "node": ">=14" } }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" + "dependencies": { + "punycode": "^2.1.0" } }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], + "node_modules/uri-js/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=12" + "node": ">=6" } }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], + "node_modules/url-join": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz", + "integrity": "sha512-H6dnQ/yPAAVzMQRvEvyz01hhfQL5qRWSEt7BX8t9DqnPw9BjMb64fjIRq76Uvf1hkHp+mTZvEVJ5guXOT0Xqaw==", + "devOptional": true + }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "devOptional": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "engines": { - "node": ">=12" + "node": ">= 0.4.0" } }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "devOptional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/valid-url": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz", + "integrity": "sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==", + "devOptional": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" } }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", "dev": true, - "optional": true, - "os": [ - "sunos" - ], "engines": { - "node": ">=12" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "engines": { - "node": ">=12" + "node": ">= 0.8" } }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=12" + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } } }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { + "node_modules/vite/node_modules/@esbuild/linux-x64": { "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">=12" diff --git a/frontend/package.json b/frontend/package.json index ac52bd0a..abf42c01 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,9 @@ "@angular/platform-server": "^18.2.1", "@angular/router": "^18.2.1", "@angular/ssr": "^18.1.2", + "@azure/msal-angular": "^5.2.7", + "@azure/msal-browser": "^5.13.0", + "d3": "^7.9.0", "express": "^4.18.2", "file-saver": "^2.0.5", "ngx-image-cropper": "^9.1.5", @@ -46,14 +49,13 @@ "rxjs": "~7.8.0", "tailwindcss": "^3.4.17", "tslib": "^2.3.0", - "zone.js": "~0.14.3", - "d3": "^7.9.0" + "zone.js": "~0.14.3" }, "devDependencies": { "@angular-devkit/build-angular": "^18.2.1", - "@types/d3": "^7.4.3", "@angular/cli": "^18.2.1", "@angular/compiler-cli": "^18.2.1", + "@types/d3": "^7.4.3", "@types/express": "^4.17.17", "@types/file-saver": "^2.0.7", "@types/jasmine": "~5.1.0", diff --git a/frontend/src/app/auth.interceptor.ts b/frontend/src/app/auth.interceptor.ts index 1faba50a..bc9a6116 100644 --- a/frontend/src/app/auth.interceptor.ts +++ b/frontend/src/app/auth.interceptor.ts @@ -35,29 +35,21 @@ export class AuthInterceptor implements HttpInterceptor { request: HttpRequest, next: HttpHandler, ): Observable> { - // Asynchronously get a valid token. This will use the cache or trigger a silent refresh. - return this.authService.getValidIdentityPlatformToken$().pipe( - switchMap(token => { - // Token was retrieved successfully. Clone the request and add the auth header. - const authorizedRequest = request.clone({ - setHeaders: {Authorization: `Bearer ${token}`}, - }); - return next.handle(authorizedRequest); - }), + let authorizedRequest = request; + + // Only attach credentials to our backend + if (request.url.startsWith(environment.backendURL) || request.url.includes('/api/')) { + authorizedRequest = request.clone({ withCredentials: true }); + } + + return next.handle(authorizedRequest).pipe( catchError(error => { - // If the error is NOT an HttpErrorResponse, it's a token refresh failure - // from our AuthService. In this case, the session is invalid, and we should log out. - if (!(error instanceof HttpErrorResponse)) { - console.error( - 'AuthInterceptor: Session expired and could not be refreshed. Logging out.', - error, - ); + // If we receive a 401 Unauthorized from the backend, our HttpOnly session + // cookie has likely expired or is invalid. We log the user out. + if (error instanceof HttpErrorResponse && error.status === 401) { + console.error('AuthInterceptor: Session expired (401). Logging out.'); void this.authService.logout(); } - - // Otherwise, it's a backend API error (e.g., 404, 500). We should NOT log out. - // We just re-throw the original HttpErrorResponse so the calling service - // (e.g., UserService) can handle it and display an appropriate error message. return throwError(() => error); }), ); diff --git a/frontend/src/app/common/services/auth.guard.service.ts b/frontend/src/app/common/services/auth.guard.service.ts index 61648e79..e8e1b08e 100644 --- a/frontend/src/app/common/services/auth.guard.service.ts +++ b/frontend/src/app/common/services/auth.guard.service.ts @@ -26,8 +26,9 @@ import {AuthService} from './auth.service'; import {UserService} from './user.service'; import {UserRolesEnum} from '../models/user.model'; import {isPlatformBrowser} from '@angular/common'; -import {Observable, of} from 'rxjs'; +import {Observable, of, firstValueFrom} from 'rxjs'; import {SettingsService} from '../../services/settings.service'; +import {environment} from '../../../environments/environment'; const LOGIN_ROUTE = '/login'; @Injectable({ @@ -43,14 +44,10 @@ export class AuthGuardService implements CanActivate { private settingsService: SettingsService, ) {} - canActivate( + async canActivate( route: ActivatedRouteSnapshot, state: RouterStateSnapshot, - ): - | Observable - | Promise - | boolean - | UrlTree { + ): Promise { if (!isPlatformBrowser(this.platformId)) { // --- SERVER SIDE --- // Allow navigation to render the basic app shell. @@ -62,11 +59,31 @@ export class AuthGuardService implements CanActivate { } // --- BROWSER SIDE --- + // Ensure MSAL has a chance to process any pending redirects before we check auth status + if (this.authService.isEntraAuth) { + await this.authService.getMsalInstance(); + } + if (!this.authService.isLoggedIn()) { + if (!environment.isLocal) { + const authStatus = await firstValueFrom(this.authService.checkIapSession()); + if (authStatus === 'authenticated') { + return this.settingsService.loadSettings().then(() => true); + } else if (authStatus === 'unauthorized') { + console.warn('IAP session unauthorized by backend. Redirecting to login to show error.'); + void this.router.navigate([LOGIN_ROUTE]); + return false; + } else { + console.warn('IAP session expired or missing. Reloading page to trigger IAP login...'); + window.location.reload(); + return false; + } + } void this.router.navigate([LOGIN_ROUTE]); return false; } + return this.settingsService.loadSettings().then(() => { const requiredRoles = route.data?.['requiredRoles'] as UserRolesEnum[]; if (requiredRoles && requiredRoles.length > 0) { diff --git a/frontend/src/app/common/services/auth.service.ts b/frontend/src/app/common/services/auth.service.ts index cc6d1db8..a6eb53f2 100644 --- a/frontend/src/app/common/services/auth.service.ts +++ b/frontend/src/app/common/services/auth.service.ts @@ -26,21 +26,20 @@ import { signInWithPopup, UserCredential, } from '@angular/fire/auth'; -import {Observable, from, throwError, of} from 'rxjs'; +import {Observable, from, throwError, of, firstValueFrom, EMPTY} from 'rxjs'; import {catchError, tap, map, switchMap} from 'rxjs/operators'; +import { PublicClientApplication, Configuration, AuthenticationResult } from '@azure/msal-browser'; import {isPlatformBrowser} from '@angular/common'; import {SettingsService} from '../../services/settings.service'; // Declare the 'google' global object from the Google Identity Services script declare const google: any; -const FIREBASE_SESSION_KEY = 'firebase_session'; const USER_DETAILS = 'USER_DETAILS'; const LOGIN_ROUTE = '/login'; - -interface FirebaseSession { - token: string; - expiry: number; // Expiration timestamp in milliseconds +const INITIAL_HASH = typeof window !== 'undefined' ? window.location.hash : ''; +if (typeof window !== 'undefined') { + (window as any).INITIAL_HASH = INITIAL_HASH; } @Injectable({ @@ -55,6 +54,11 @@ export class AuthService { private currentOAuthAccessToken: string | null = null; private firebaseIdToken: string | null = null; // To store the Firebase token for the test private firebaseTokenExpiry: number | null = null; // To store token expiration time (in ms) + private msalInstance: PublicClientApplication | null = null; + + get isEntraAuth(): boolean { + return !!environment.ENTRA_CLIENT_ID && environment.ENTRA_CLIENT_ID !== 'ENTRA_CLIENT_ID_PLACEHOLDER'; + } constructor( private router: Router, @@ -67,6 +71,9 @@ export class AuthService { prompt: 'select_account', }); this.loadSessionFromStorage(); + if (isPlatformBrowser(this.platformId) && this.isEntraAuth) { + this.getMsalInstance(); + } } /** @@ -90,11 +97,9 @@ export class AuthService { const token = idTokenResult.token; const expirationTime = Date.parse(idTokenResult.expirationTime); - // Save session details to memory and local storage. + // Save session details to memory. this.firebaseIdToken = token; this.firebaseTokenExpiry = expirationTime; - const session: FirebaseSession = {token, expiry: expirationTime}; - localStorage.setItem(FIREBASE_SESSION_KEY, JSON.stringify(session)); // Call the backend to get or create the user profile. return this.syncUserWithBackend$(token).pipe( @@ -138,9 +143,6 @@ export class AuthService { this.firebaseIdToken = token; this.firebaseTokenExpiry = expiry; - - const session: FirebaseSession = {token, expiry}; - localStorage.setItem(FIREBASE_SESSION_KEY, JSON.stringify(session)); }), ); } @@ -166,12 +168,6 @@ export class AuthService { this.firebaseIdToken = idToken; this.firebaseTokenExpiry = payload.exp * 1000; - const session: FirebaseSession = { - token: idToken, - expiry: this.firebaseTokenExpiry, - }; - localStorage.setItem(FIREBASE_SESSION_KEY, JSON.stringify(session)); - // Call the backend to get or create the user profile. return this.syncUserWithBackend$(idToken).pipe( switchMap(() => from(this.settingsService.loadSettings())), @@ -181,6 +177,82 @@ export class AuthService { ); } + private msalInitPromise: Promise | null = null; + + async getMsalInstance(): Promise { + if (!this.msalInitPromise) { + this.msalInitPromise = this._initMsal(); + } + return this.msalInitPromise; + } + + private async _initMsal(): Promise { + const msalConfig: Configuration = { + auth: { + clientId: environment.ENTRA_CLIENT_ID, + authority: `https://login.microsoftonline.com/${environment.ENTRA_TENANT_ID}/v2.0`, + redirectUri: window.location.origin, + navigateToLoginRequestUrl: false + } as any, + cache: { + cacheLocation: 'localStorage', + } + }; + this.msalInstance = new PublicClientApplication(msalConfig); + await this.msalInstance.initialize(); + + try { + if (INITIAL_HASH && !window.location.hash) { + window.location.hash = INITIAL_HASH; + } + const result = await this.msalInstance.handleRedirectPromise(); + if (result) { + const idToken = result.idToken; + const payload = JSON.parse(atob(idToken.split('.')[1])); + + this.firebaseIdToken = idToken; + this.firebaseTokenExpiry = payload.exp * 1000; + + this.firebaseIdToken = idToken; + this.firebaseTokenExpiry = payload.exp * 1000; + + await firstValueFrom(this.syncUserWithBackend$(idToken)); + await this.settingsService.loadSettings(); + // After successfully processing redirect, navigate to home + this.router.navigate([LOGIN_ROUTE]).then(() => this.router.navigate(['/'])); + } + } catch (error) { + localStorage.setItem('MSAL_DEBUG_ERROR', (error as any)?.message || String(error)); + console.error('Error handling MSAL redirect:', error); + } + return this.msalInstance; + } + + signInWithMicrosoftEntra(): Observable { + return from(this.getMsalInstance()).pipe( + switchMap(msal => from(msal.loginPopup({ scopes: ['User.Read'] }))), + switchMap((result: AuthenticationResult) => { + const idToken = result.idToken; + const payload = JSON.parse(atob(idToken.split('.')[1])); + + this.firebaseIdToken = idToken; + this.firebaseTokenExpiry = payload.exp * 1000; + + this.firebaseIdToken = idToken; + this.firebaseTokenExpiry = payload.exp * 1000; + + return this.syncUserWithBackend$(idToken).pipe( + switchMap(() => from(this.settingsService.loadSettings())), + map(() => idToken) + ); + }), + catchError(error => { + console.error('Error during MSAL login:', error); + return throwError(() => error); + }) + ); + } + private promptForIdentityPlatformToken$(): Observable { const GOOGLE_CLIENT_ID = environment.GOOGLE_CLIENT_ID; @@ -244,41 +316,71 @@ export class AuthService { // First, check our own session info which is loaded from localStorage. // This is synchronous and tells us if we have a valid, non-expired token. if (!this.isLoggedIn()) { + console.log('getValidIdentityPlatformToken: isLoggedIn is FALSE'); return of(); } // Fallback case: The Firebase Auth instance is not yet initialized, but we // have a valid token from localStorage. We can use this for the current // request. The next request will likely hit the ideal case above. + console.log('getValidIdentityPlatformToken: isLoggedIn is TRUE, returning token'); return of(this.firebaseIdToken!); } + checkIapSession(): Observable<'authenticated' | 'unauthenticated' | 'unauthorized'> { + return this.httpClient.get(`${environment.backendURL}/users/me`, {withCredentials: true}).pipe( + tap((userDetails: UserModel) => { + localStorage.setItem(USER_DETAILS, JSON.stringify(userDetails)); + console.log('IAP Session detected and synchronized.'); + }), + map(() => 'authenticated' as const), + catchError((error) => { + console.log('No active IAP session found.', error); + if (error instanceof HttpErrorResponse && error.status === 401) { + return of('unauthorized' as const); + } + return of('unauthenticated' as const); + }) + ); + } + private syncUserWithBackend$(token: string): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${token}`); - return this.httpClient - .get(`${environment.backendURL}/users/me`, {headers}) - .pipe( - tap((userDetails: UserModel) => { - // The backend is the source of truth. Save the returned profile to local storage. - localStorage.setItem(USER_DETAILS, JSON.stringify(userDetails)); - console.log('User profile successfully synced with backend.'); - }), - catchError((error: HttpErrorResponse) => { - console.error('Failed to sync user with backend', error); - // This is a critical error, so we should propagate it. - return throwError( - () => - new Error( - error?.error?.detail || - `Could not synchronize user profile with the server. ${error?.error?.detail}`, - ), - ); - }), - ); + + const headers = new HttpHeaders().set('X-Custom-Auth', `Bearer ${token}`); + + // First, exchange the token for an HttpOnly session cookie + return this.httpClient.post(`${environment.backendURL}/auth/session`, {}, {headers, withCredentials: true}).pipe( + // Then, fetch the user profile using the newly set cookie + switchMap(() => this.httpClient.get(`${environment.backendURL}/users/me`, {withCredentials: true})), + tap((userDetails: UserModel) => { + // The backend is the source of truth. Save the returned profile to local storage. + localStorage.setItem(USER_DETAILS, JSON.stringify(userDetails)); + console.log('User profile successfully synced with backend.'); + }), + catchError((error: HttpErrorResponse) => { + console.error('Failed to sync user with backend', error); + // This is a critical error, so we should propagate it. + return throwError( + () => + new Error( + error?.error?.detail || + `Could not synchronize user profile with the server. ${error?.error?.detail}`, + ), + ); + }), + ); } async logout(route: string = LOGIN_ROUTE) { this.settingsService.reset(); + + // Attempt to log out of the backend first to clear the session cookie + try { + await firstValueFrom(this.httpClient.post(`${environment.backendURL}/auth/logout`, {}, {withCredentials: true})); + } catch (e) { + console.error('Backend logout failed', e); + } + return this.auth .signOut() .then(() => { @@ -286,7 +388,6 @@ export class AuthService { // Clear Firebase session data this.firebaseIdToken = null; this.firebaseTokenExpiry = null; - localStorage.removeItem(FIREBASE_SESSION_KEY); localStorage.removeItem(USER_DETAILS); localStorage.removeItem('showTooltip'); void this.router.navigateByUrl(route); @@ -294,7 +395,6 @@ export class AuthService { .catch(e => { console.error('Sign Out Error', e); this.settingsService.reset(); - localStorage.removeItem(FIREBASE_SESSION_KEY); localStorage.removeItem(USER_DETAILS); localStorage.removeItem('showTooltip'); void this.router.navigate([LOGIN_ROUTE]); @@ -304,13 +404,7 @@ export class AuthService { isLoggedIn() { if (!isPlatformBrowser(this.platformId)) return false; - // Check if the in-memory token is valid - const now = Date.now(); - const isTokenValid = !!( - this.firebaseIdToken && - this.firebaseTokenExpiry && - this.firebaseTokenExpiry > now - ); + const isTokenValid = localStorage.getItem(USER_DETAILS) !== null; if (!isTokenValid && this.router.url !== LOGIN_ROUTE) { void this.router.navigate([LOGIN_ROUTE]); @@ -321,25 +415,13 @@ export class AuthService { private loadSessionFromStorage(): void { if (!isPlatformBrowser(this.platformId)) return; - - const sessionStr = localStorage.getItem(FIREBASE_SESSION_KEY); - if (sessionStr) { - const session: FirebaseSession = JSON.parse(sessionStr); - // Check if the stored session is still valid - if (session.expiry > Date.now()) { - this.firebaseIdToken = session.token; - this.firebaseTokenExpiry = session.expiry; - } else { - // If expired, remove it from storage. - localStorage.removeItem(FIREBASE_SESSION_KEY); - } - } + // Session is now managed via HttpOnly cookies, so we don't load tokens from localStorage. } isUserLoggedIn() { if (!isPlatformBrowser(this.platformId)) return false; - const isUserLoggedIn = localStorage.getItem(FIREBASE_SESSION_KEY) !== null; + const isUserLoggedIn = localStorage.getItem(USER_DETAILS) !== null; return isUserLoggedIn; } diff --git a/frontend/src/app/login/login.component.html b/frontend/src/app/login/login.component.html index 7b06b145..5d816b2a 100644 --- a/frontend/src/app/login/login.component.html +++ b/frontend/src/app/login/login.component.html @@ -41,12 +41,16 @@

+
+

Authorization Failed

+

Your user profile could not be created or matched against allowed organizations. Please verify that your email domain is allowed to access this application.

+
@@ -69,22 +73,28 @@

- Single Sign on with your Google Account + Single Sign on with your {{ isEntraAuth ? 'Microsoft' : 'Google' }} Account

+
+

Authorization Failed

+

Your user profile could not be created or matched against allowed organizations. Please verify that your email domain is allowed to access this application.

+
+ + diff --git a/frontend/src/app/login/login.component.ts b/frontend/src/app/login/login.component.ts index 496705e1..b30a2f73 100644 --- a/frontend/src/app/login/login.component.ts +++ b/frontend/src/app/login/login.component.ts @@ -42,6 +42,8 @@ export class LoginComponent { invalidLogin = false; errorMessage = ''; isBrowser: boolean; + isEntraAuth = !!environment.ENTRA_CLIENT_ID && environment.ENTRA_CLIENT_ID !== 'ENTRA_CLIENT_ID_PLACEHOLDER'; + constructor( private authService: AuthService, @@ -56,7 +58,69 @@ export class LoginComponent { }); } - ngOnInit(): void {} + iapAuthError = false; + + ngOnInit(): void { + const msalError = localStorage.getItem('MSAL_DEBUG_ERROR'); + if (msalError) { + localStorage.removeItem('MSAL_DEBUG_ERROR'); + } + + if (this.authService.isLoggedIn()) { + void this.router.navigate([HOME_ROUTE]); + } else if (!environment.isLocal) { + this.authService.checkIapSession().subscribe((authStatus) => { + if (authStatus === 'authenticated') { + sessionStorage.removeItem('iap_redirect_count'); + void this.router.navigate([HOME_ROUTE]); + } else if (authStatus === 'unauthorized') { + console.error('IAP session failed authorization check (unauthorized domain).'); + this.iapAuthError = true; + } else { + const redirectCount = parseInt(sessionStorage.getItem('iap_redirect_count') || '0', 10); + if (redirectCount < 2) { + sessionStorage.setItem('iap_redirect_count', (redirectCount + 1).toString()); + window.location.href = HOME_ROUTE; + } else { + console.error('IAP session failed authorization check (loop prevented).'); + this.iapAuthError = true; + } + } + }); + } + } + + login() { + if (this.isEntraAuth) { + this.loginWithMicrosoft(); + } else { + this.loginWithGoogle(); + } + } + + loginWithMicrosoft() { + this.loader = true; + this.invalidLogin = false; + this.errorMessage = ''; + + this.authService.signInWithMicrosoftEntra().subscribe({ + next: (token: string) => { + this.ngZone.run(() => { + this.loader = false; + void this.router.navigate([HOME_ROUTE]); + }); + }, + error: error => { + this.loader = false; + console.error('Microsoft Login Process Error:', error); + this.handleLoginError( + error || { + message: 'An unexpected error occurred during sign-in. Please try again.', + }, + ); + }, + }); + } loginWithGoogle() { this.loader = true; diff --git a/frontend/src/environments/environment.development.ts.example b/frontend/src/environments/environment.development.ts.example new file mode 100644 index 00000000..5b08c34e --- /dev/null +++ b/frontend/src/environments/environment.development.ts.example @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const environment = { + firebase: { + apiKey: 'YOUR_FIREBASE_API_KEY', + authDomain: 'YOUR_FIREBASE_AUTH_DOMAIN', + projectId: 'YOUR_FIREBASE_PROJECT_ID', + storageBucket: 'YOUR_FIREBASE_STORAGE_BUCKET', + messagingSenderId: 'YOUR_FIREBASE_SENDER_ID', + appId: 'YOUR_FIREBASE_APP_ID', + measurementId: 'YOUR_FIREBASE_MEASUREMENT_ID', + }, + production: false, + isLocal: true, + backendURL: 'http://localhost:8080/api', + EMAIL_REGEX: + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ADMIN: 'admin', + GOOGLE_CLIENT_ID: 'YOUR_GOOGLE_CLIENT_ID', + ENTRA_CLIENT_ID: 'YOUR_ENTRA_CLIENT_ID', + ENTRA_TENANT_ID: 'YOUR_ENTRA_TENANT_ID', +}; diff --git a/frontend/src/environments/environment.prod.ts b/frontend/src/environments/environment.prod.ts index 96d34c9f..1bbeb350 100644 --- a/frontend/src/environments/environment.prod.ts +++ b/frontend/src/environments/environment.prod.ts @@ -31,4 +31,6 @@ export const environment = { /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, ADMIN: 'admin', GOOGLE_CLIENT_ID: 'GOOGLE_CLIENT_ID_PLACEHOLDER', + ENTRA_CLIENT_ID: 'ENTRA_CLIENT_ID_PLACEHOLDER', + ENTRA_TENANT_ID: 'ENTRA_TENANT_ID_PLACEHOLDER', }; diff --git a/frontend/src/environments/environment.test.ts.example b/frontend/src/environments/environment.test.ts.example new file mode 100644 index 00000000..5f247bd6 --- /dev/null +++ b/frontend/src/environments/environment.test.ts.example @@ -0,0 +1,36 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const environment = { + firebase: { + apiKey: 'YOUR_FIREBASE_API_KEY', + authDomain: 'YOUR_FIREBASE_AUTH_DOMAIN', + projectId: 'YOUR_FIREBASE_PROJECT_ID', + storageBucket: 'YOUR_FIREBASE_STORAGE_BUCKET', + messagingSenderId: 'YOUR_FIREBASE_SENDER_ID', + appId: 'YOUR_FIREBASE_APP_ID', + measurementId: 'YOUR_FIREBASE_MEASUREMENT_ID', + }, + production: false, + isLocal: false, + backendURL: 'YOUR_BACKEND_TEST_URL', + EMAIL_REGEX: + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, + ADMIN: 'admin', + GOOGLE_CLIENT_ID: 'YOUR_GOOGLE_CLIENT_ID', + ENTRA_CLIENT_ID: 'YOUR_ENTRA_CLIENT_ID', + ENTRA_TENANT_ID: 'YOUR_ENTRA_TENANT_ID', +}; diff --git a/frontend/src/environments/environment.ts b/frontend/src/environments/environment.ts index 05926603..1f050df2 100644 --- a/frontend/src/environments/environment.ts +++ b/frontend/src/environments/environment.ts @@ -31,4 +31,6 @@ export const environment = { /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/, ADMIN: 'admin', GOOGLE_CLIENT_ID: '', + ENTRA_CLIENT_ID: '', + ENTRA_TENANT_ID: '', }; diff --git a/infra/environments/dev-infra-example/backend.tf b/infra/environments/dev-infra-example/backend.tf index 9c3fe665..ab91bb34 100644 --- a/infra/environments/dev-infra-example/backend.tf +++ b/infra/environments/dev-infra-example/backend.tf @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -terraform { - backend "gcs" { - bucket = "cstudio-infra-example-cstudio-dev-tfstate" - prefix = "infra/dev/state" - } -} +# terraform { +# backend "gcs" { +# bucket = "cstudio-infra-example-cstudio-dev-tfstate" +# prefix = "infra/dev/state" +# } +# } diff --git a/infra/environments/dev-infra-example/dev.tfvars b/infra/environments/dev-infra-example/dev.tfvars index 547b509a..301fcb29 100644 --- a/infra/environments/dev-infra-example/dev.tfvars +++ b/infra/environments/dev-infra-example/dev.tfvars @@ -72,3 +72,16 @@ apis_to_enable = [ "texttospeech.googleapis.com", "workflows.googleapis.com", ] + +entra_client_id = "YOUR_ENTRA_CLIENT_ID" +entra_tenant_id = "YOUR_ENTRA_TENANT_ID" +entra_client_secret = "YOUR_ENTRA_CLIENT_SECRET" + +# --- IAP & Workforce Identity Configuration --- +iap_oauth2_client_id = "YOUR_IAP_OAUTH2_CLIENT_ID" +iap_oauth2_client_secret = "YOUR_IAP_OAUTH2_CLIENT_SECRET" +iap_expected_audience = "YOUR_IAP_EXPECTED_AUDIENCE" +domain_name = "YOUR_CUSTOM_DOMAIN_NAME_OR_IP" +iap_access_members = ["principalSet://iam.googleapis.com/locations/global/workforcePools/YOUR_WORKFORCE_POOL_ID/*"] +workforce_pool_id = "YOUR_WORKFORCE_POOL_ID" +org_id = "YOUR_GCP_ORGANIZATION_ID" diff --git a/infra/environments/dev-infra-example/main.tf b/infra/environments/dev-infra-example/main.tf index ddb95741..8de0dabe 100644 --- a/infra/environments/dev-infra-example/main.tf +++ b/infra/environments/dev-infra-example/main.tf @@ -62,6 +62,17 @@ module "creative_studio_platform" { frontend_secrets = var.frontend_secrets backend_secrets = var.backend_secrets fe_build_substitutions = var.fe_build_substitutions + entra_client_id = var.entra_client_id + entra_tenant_id = var.entra_tenant_id + entra_client_secret = var.entra_client_secret + + iap_oauth2_client_id = var.iap_oauth2_client_id + iap_oauth2_client_secret = var.iap_oauth2_client_secret + domain_name = var.domain_name + iap_expected_audience = var.iap_expected_audience + iap_access_members = var.iap_access_members + workforce_pool_id = var.workforce_pool_id + org_id = var.org_id depends_on = [ google_project_service.apis ] } diff --git a/infra/environments/dev-infra-example/variables.tf b/infra/environments/dev-infra-example/variables.tf index 6479309a..2659d7ea 100644 --- a/infra/environments/dev-infra-example/variables.tf +++ b/infra/environments/dev-infra-example/variables.tf @@ -125,3 +125,64 @@ variable "apis_to_enable" { "run.googleapis.com" # Required for Cloud Run ] } + +variable "entra_client_id" { + type = string + description = "Microsoft Entra Client ID for alternative authentication." + default = "" +} + +variable "entra_tenant_id" { + type = string + description = "Microsoft Entra Tenant ID for alternative authentication." + default = "" +} + +variable "entra_client_secret" { + type = string + description = "Microsoft Entra Client Secret for Workforce Identity Federation" + default = "" + sensitive = true +} + +variable "iap_oauth2_client_id" { + type = string + description = "Client ID for IAP OAuth configuration." + default = "" +} + +variable "iap_oauth2_client_secret" { + type = string + description = "Client Secret for IAP OAuth configuration." + default = "" +} + +variable "domain_name" { + type = string + description = "Custom domain name mapped to the application load balancer." + default = "" +} + +variable "iap_expected_audience" { + type = string + description = "Expected audience claim in backend verified IAP JWT tokens." + default = "" +} + +variable "iap_access_members" { + type = list(string) + description = "List of identity members/principals allowed to access the application via IAP." + default = [] +} + +variable "workforce_pool_id" { + type = string + description = "Workforce Identity Pool ID." + default = "" +} + +variable "org_id" { + type = string + description = "GCP Organization ID for Workforce Identity Federation." + default = "" +} diff --git a/infra/modules/cloud-run-service/main.tf b/infra/modules/cloud-run-service/main.tf index a3c96a06..e2759b4e 100644 --- a/infra/modules/cloud-run-service/main.tf +++ b/infra/modules/cloud-run-service/main.tf @@ -36,6 +36,8 @@ resource "google_cloud_run_v2_service" "this" { location = var.gcp_region custom_audiences = var.custom_audiences deletion_protection = false + ingress = var.ingress + template { service_account = google_service_account.run_sa.email @@ -158,6 +160,12 @@ resource "google_artifact_registry_repository_iam_member" "ar_writer_binding" { member = "serviceAccount:${google_service_account.trigger_sa.email}" } +resource "google_project_iam_member" "trigger_storage_viewer" { + project = var.gcp_project_id + role = "roles/storage.objectViewer" + member = "serviceAccount:${google_service_account.trigger_sa.email}" +} + resource "google_cloud_run_v2_service_iam_member" "run_developer_binding" { name = google_cloud_run_v2_service.this.name location = google_cloud_run_v2_service.this.location diff --git a/infra/modules/cloud-run-service/variables.tf b/infra/modules/cloud-run-service/variables.tf index 2ae93530..a4a4add8 100644 --- a/infra/modules/cloud-run-service/variables.tf +++ b/infra/modules/cloud-run-service/variables.tf @@ -129,3 +129,10 @@ variable "db_secret_id" { } variable "db_name" { type = string } variable "db_user" { type = string } + +variable "ingress" { + type = string + description = "Ingress settings for the Cloud Run service. Allowed values: INGRESS_TRAFFIC_ALL, INGRESS_TRAFFIC_INTERNAL_ONLY, INGRESS_TRAFFIC_INTERNAL_AND_GCLB." + default = "INGRESS_TRAFFIC_ALL" +} + diff --git a/infra/modules/iap-load-balancer/main.tf b/infra/modules/iap-load-balancer/main.tf new file mode 100644 index 00000000..56fa54be --- /dev/null +++ b/infra/modules/iap-load-balancer/main.tf @@ -0,0 +1,301 @@ +terraform { + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 5.0" + } + } +} + +data "google_project" "project" { + project_id = var.gcp_project_id +} + +locals { + # If org_id is provided, we create a pool called cs-workforce-pool, otherwise use the provided pool ID + resolved_pool_id = var.org_id != "" ? "locations/global/workforcePools/cs-workforce-pool" : var.workforce_pool_id + use_workforce = local.resolved_pool_id != "" +} + +# --- 1. Workforce Identity Federation --- + +resource "google_iam_workforce_pool" "pool" { + count = var.org_id != "" ? 1 : 0 + provider = google-beta + workforce_pool_id = "cs-workforce-pool" + parent = "organizations/${var.org_id}" + location = "global" +} + +resource "google_iam_workforce_pool_provider" "entra" { + count = (var.org_id != "" && var.entra_tenant_id != "" && var.entra_client_id != "") ? 1 : 0 + provider = google-beta + workforce_pool_id = google_iam_workforce_pool.pool[0].workforce_pool_id + provider_id = "entra-provider" + location = "global" + + oidc { + issuer_uri = "https://login.microsoftonline.com/${var.entra_tenant_id}/v2.0" + client_id = var.entra_client_id + + client_secret { + value { + plain_text = var.entra_client_secret + } + } + + web_sso_config { + response_type = "CODE" + assertion_claims_behavior = "MERGE_USER_INFO_OVER_ID_TOKEN_CLAIMS" + } + } + + + attribute_mapping = { + "google.subject" = "assertion.sub" + "google.display_name" = "assertion.name" + "google.email" = "has(assertion.email) ? assertion.email : assertion.preferred_username" + } + +} + +# --- 2. Load Balancer Resources --- + +# IP Address for the Load Balancer +resource "google_compute_global_address" "lb_ip" { + name = "cs-backend-lb-ip" +} + +# Serverless NEG pointing to Cloud Run backend +resource "google_compute_region_network_endpoint_group" "be_neg" { + name = "cs-backend-neg" + network_endpoint_type = "SERVERLESS" + region = var.gcp_region + + cloud_run { + service = var.backend_service_name + } +} + +# Serverless NEG pointing to Cloud Run frontend +resource "google_compute_region_network_endpoint_group" "fe_neg" { + name = "cs-frontend-neg" + network_endpoint_type = "SERVERLESS" + region = var.gcp_region + + cloud_run { + service = var.frontend_service_name + } +} + +# Backend Service with IAP Enabled (Backend API) +resource "google_compute_backend_service" "be_service" { + name = "cs-backend-lb-service" + protocol = "HTTPS" + load_balancing_scheme = "EXTERNAL_MANAGED" + + backend { + group = google_compute_region_network_endpoint_group.be_neg.id + } + + # If IAP Client ID and Secret are provided, configure IAP + dynamic "iap" { + for_each = (var.iap_oauth2_client_id != "" && var.iap_oauth2_client_secret != "") ? [1] : [] + content { + oauth2_client_id = var.iap_oauth2_client_id + oauth2_client_secret = var.iap_oauth2_client_secret + enabled = true + } + } +} + +# Backend Service with IAP Enabled (Frontend Static) +resource "google_compute_backend_service" "fe_service" { + name = "cs-frontend-lb-service" + protocol = "HTTPS" + load_balancing_scheme = "EXTERNAL_MANAGED" + + backend { + group = google_compute_region_network_endpoint_group.fe_neg.id + } + + dynamic "iap" { + for_each = (var.iap_oauth2_client_id != "" && var.iap_oauth2_client_secret != "") ? [1] : [] + content { + oauth2_client_id = var.iap_oauth2_client_id + oauth2_client_secret = var.iap_oauth2_client_secret + enabled = true + } + } +} + +# URL Map to route traffic (Default to Frontend, /api/* to Backend) +resource "google_compute_url_map" "url_map" { + name = "cs-backend-url-map" + default_service = google_compute_backend_service.fe_service.id + + host_rule { + hosts = ["*"] + path_matcher = "all-paths" + } + + path_matcher { + name = "all-paths" + default_service = google_compute_backend_service.fe_service.id + + path_rule { + paths = ["/api", "/api/*"] + service = google_compute_backend_service.be_service.id + } + } +} + + +# SSL Certificate (if domain name is provided, use Google managed, else fallback to a placeholder self-signed or unmanaged) +resource "google_compute_managed_ssl_certificate" "lb_cert" { + count = var.domain_name != "" ? 1 : 0 + name = "cs-backend-lb-cert" + managed { + domains = [var.domain_name] + } +} + +# In a production context with no domain name, you might need a self-signed cert. +# For automation, we will define a self-signed SSL certificate if domain_name is empty. +resource "tls_private_key" "example" { + count = var.domain_name == "" ? 1 : 0 + algorithm = "RSA" + rsa_bits = 2048 +} + +resource "tls_self_signed_cert" "example" { + count = var.domain_name == "" ? 1 : 0 + private_key_pem = tls_private_key.example[0].private_key_pem + + subject { + common_name = "example.com" + organization = "Creative Studio Local" + } + + validity_period_hours = 8760 # 1 year + + allowed_uses = [ + "key_encipherment", + "digital_signature", + "server_auth", + ] +} + +resource "google_compute_ssl_certificate" "fallback_cert" { + count = var.domain_name == "" ? 1 : 0 + name = "cs-backend-fallback-cert" + private_key = tls_private_key.example[0].private_key_pem + certificate = tls_self_signed_cert.example[0].cert_pem +} + +# Target HTTPS Proxy +resource "google_compute_target_https_proxy" "https_proxy" { + name = "cs-backend-https-proxy" + url_map = google_compute_url_map.url_map.id + ssl_certificates = compact([ + var.domain_name != "" ? google_compute_managed_ssl_certificate.lb_cert[0].id : "", + var.domain_name == "" ? google_compute_ssl_certificate.fallback_cert[0].id : "" + ]) +} + + +# Forwarding Rule (Frontend IP) +resource "google_compute_global_forwarding_rule" "forwarding_rule" { + name = "cs-backend-forwarding-rule" + ip_address = google_compute_global_address.lb_ip.address + target = google_compute_target_https_proxy.https_proxy.id + port_range = "443" + load_balancing_scheme = "EXTERNAL_MANAGED" +} + +# Allow IAP service agent to access Cloud Run +resource "google_project_service_identity" "iap_sa" { + provider = google-beta + project = var.gcp_project_id + service = "iap.googleapis.com" +} + +resource "google_cloud_run_v2_service_iam_member" "iap_can_invoke_backend" { + project = var.gcp_project_id + name = var.backend_service_name + location = var.gcp_region + role = "roles/run.invoker" + member = "serviceAccount:${google_project_service_identity.iap_sa.email}" +} + +resource "google_cloud_run_v2_service_iam_member" "iap_can_invoke_frontend" { + project = var.gcp_project_id + name = var.frontend_service_name + location = var.gcp_region + role = "roles/run.invoker" + member = "serviceAccount:${google_project_service_identity.iap_sa.email}" +} + +# Grant users IAP secured Web App User role on backend service +resource "google_iap_web_backend_service_iam_member" "member" { + for_each = toset(var.iap_access_members) + project = var.gcp_project_id + web_backend_service = google_compute_backend_service.be_service.name + role = "roles/iap.httpsResourceAccessor" + member = each.key +} + +# Grant users IAP secured Web App User role on frontend service +resource "google_iap_web_backend_service_iam_member" "fe_member" { + for_each = toset(var.iap_access_members) + project = var.gcp_project_id + web_backend_service = google_compute_backend_service.fe_service.name + role = "roles/iap.httpsResourceAccessor" + member = each.key +} + +# Configure IAP Settings to prioritize Workforce Identity Federation on backend service +resource "google_iap_settings" "default" { + count = (var.iap_oauth2_client_id != "" && local.use_workforce) ? 1 : 0 + provider = google-beta + name = "projects/${var.gcp_project_id}/iap_web/compute/services/${google_compute_backend_service.be_service.name}" + + access_settings { + identity_sources = ["WORKFORCE_IDENTITY_FEDERATION"] + + workforce_identity_settings { + workforce_pools = [local.resolved_pool_id] + oauth2 { + client_id = var.iap_oauth2_client_id + client_secret = var.iap_oauth2_client_secret + } + } + } +} + +# Configure IAP Settings to prioritize Workforce Identity Federation on frontend service +resource "google_iap_settings" "fe_default" { + count = (var.iap_oauth2_client_id != "" && local.use_workforce) ? 1 : 0 + provider = google-beta + name = "projects/${var.gcp_project_id}/iap_web/compute/services/${google_compute_backend_service.fe_service.name}" + + access_settings { + identity_sources = ["WORKFORCE_IDENTITY_FEDERATION"] + + workforce_identity_settings { + workforce_pools = [local.resolved_pool_id] + oauth2 { + client_id = var.iap_oauth2_client_id + client_secret = var.iap_oauth2_client_secret + } + } + } +} + + + + diff --git a/infra/modules/iap-load-balancer/outputs.tf b/infra/modules/iap-load-balancer/outputs.tf new file mode 100644 index 00000000..74bedcd1 --- /dev/null +++ b/infra/modules/iap-load-balancer/outputs.tf @@ -0,0 +1,9 @@ +output "load_balancer_ip" { + value = google_compute_global_address.lb_ip.address + description = "The external IP address of the Global HTTP(S) Load Balancer." +} + +output "iap_expected_audience" { + value = "/projects/${data.google_project.project.number}/global/backendServices/${google_compute_backend_service.be_service.generated_id}" + description = "The expected Audience (aud) claim for IAP JWT validation." +} diff --git a/infra/modules/iap-load-balancer/variables.tf b/infra/modules/iap-load-balancer/variables.tf new file mode 100644 index 00000000..d280f492 --- /dev/null +++ b/infra/modules/iap-load-balancer/variables.tf @@ -0,0 +1,79 @@ +variable "gcp_project_id" { + type = string + description = "The GCP project ID" +} + +variable "gcp_region" { + type = string + description = "The GCP region for the Cloud Run service" +} + +variable "backend_service_name" { + type = string + description = "The name of the Cloud Run backend service" +} + +variable "frontend_service_name" { + type = string + description = "The name of the Cloud Run frontend service" +} + + +variable "org_id" { + type = string + description = "The Organization ID for the Workforce Identity Pool" + default = "" +} + +variable "entra_client_id" { + type = string + description = "Microsoft Entra Client ID for Workforce Identity Federation" + default = "" +} + +variable "entra_tenant_id" { + type = string + description = "Microsoft Entra Tenant ID for Workforce Identity Federation" + default = "" +} + +variable "entra_client_secret" { + type = string + description = "Microsoft Entra Client Secret for Workforce Identity Federation" + default = "" + sensitive = true +} + + +variable "iap_oauth2_client_id" { + type = string + description = "OAuth2 Client ID for Identity-Aware Proxy" + default = "" +} + +variable "iap_oauth2_client_secret" { + type = string + description = "OAuth2 Client Secret for Identity-Aware Proxy" + default = "" + sensitive = true +} + +variable "domain_name" { + type = string + description = "The domain name for the Load Balancer Managed SSL Certificate" + default = "" +} + +variable "iap_access_members" { + type = list(string) + description = "The list of IAM members allowed to access the application via IAP (e.g. user:email@domain.com, group:email@domain.com, domain:domain.com)." + default = [] +} + +variable "workforce_pool_id" { + type = string + description = "An existing Workforce Identity Pool ID (e.g. cs-workforce-pool). Required if using an existing pool instead of creating a new one." + default = "" +} + + diff --git a/infra/modules/platform/main.tf b/infra/modules/platform/main.tf index 1c542c4e..87e088ca 100644 --- a/infra/modules/platform/main.tf +++ b/infra/modules/platform/main.tf @@ -51,7 +51,9 @@ data "google_project" "project" { # --- Predictable URLs & Environment Variables --- locals { region_code = join("", [for s in split("-", var.gcp_region) : substr(s, 0, 1)]) - backend_url = "https://${var.backend_service_name}-${data.google_project.project.number}.${var.gcp_region}.run.app" + + # Use LB URL if IAP is enabled, otherwise use the direct Cloud Run URL + backend_url = var.iap_oauth2_client_id != "" ? (var.domain_name != "" ? "https://${var.domain_name}" : "https://${module.iap_load_balancer[0].load_balancer_ip}") : "https://${var.backend_service_name}-${data.google_project.project.number}.${var.gcp_region}.run.app" frontend_url = "https://${var.firebase_site_id}.web.app" # Predictable Firebase URL @@ -64,11 +66,13 @@ locals { "SIGNING_SA_EMAIL" = google_service_account.bucket_reader_sa.email "BACKEND_URL" = local.backend_url "WORKFLOWS_EXECUTOR_URL" = "${local.backend_url}/api/workflows-executor" + "IAP_EXPECTED_AUDIENCE" = var.iap_expected_audience } ) } + # --- Cloud Build Repository Connection --- resource "google_cloudbuildv2_repository" "source_repo" { provider = google-beta @@ -132,41 +136,57 @@ module "backend_service" { # Pass the Secret ID reference (NOT the value) for Cloud Run db_secret_id = "creative-studio-db-password" + + # Restrict ingress to Internal + GCLB if IAP is enabled + ingress = var.iap_oauth2_client_id != "" ? "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" : "INGRESS_TRAFFIC_ALL" } + + resource "google_firebase_project" "default" { provider = google-beta project = var.gcp_project_id } module "frontend_service" { - source = "../firebase-hosting-service" + source = "../cloud-run-service" - source_repository_id = google_cloudbuildv2_repository.source_repo.id - gcp_project_id = var.gcp_project_id + gcp_project_id = var.gcp_project_id gcp_region = var.gcp_region - firebase_project_id = google_firebase_project.default.project - service_name = var.gcp_project_id - environment = var.environment - resource_prefix = "cs-fe" - github_branch_name = var.github_branch_name - cloudbuild_yaml_path = "frontend/cloudbuild-deploy.yaml" - included_files_glob = ["frontend/**"] - firebase_site_id = var.firebase_site_id != "" ? var.firebase_site_id : var.gcp_project_id - - build_substitutions = merge( - var.fe_build_substitutions, + environment = var.environment + service_name = var.frontend_service_name + resource_prefix = "cs-fe" + github_conn_name = var.github_conn_name + github_repo_owner = var.github_repo_owner + github_repo_name = var.github_repo_name + github_branch_name = var.github_branch_name + cloudbuild_yaml_path = "frontend/cloudbuild-run.yaml" + included_files_glob = ["frontend/**"] + container_env_vars = {} + runtime_secrets = {} + custom_audiences = var.frontend_custom_audiences + scaling_min_instances = 1 + source_repository_id = google_cloudbuildv2_repository.source_repo.id + cpu = "1000m" + memory = "1024Mi" + build_substitutions = merge(var.fe_build_substitutions, { - # This block should ONLY contain non-secret, underscore-prefixed values - _BACKEND_URL = local.frontend_url # The frontend will redirect the api calls to the backend - _FE_SERVICE_NAME = var.frontend_service_name - _BACKEND_SERVICE_ID = var.backend_service_name - _FIREBASE_PROJECT_ID = var.gcp_project_id - _FIREBASE_SITE_ID = var.firebase_site_id != "" ? var.firebase_site_id : var.gcp_project_id + _REGION = var.gcp_region + _SERVICE_NAME = var.frontend_service_name } ) + + # database (dummy values to satisfy variables) + cloud_sql_connection_name = module.postgresql.connection_name + db_name = module.postgresql.db_name + db_user = module.postgresql.db_user + db_secret_id = "creative-studio-db-password" + + # Restrict ingress to Internal + GCLB + ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" } + module "frontend_secrets" { source = "../secret-manager" @@ -195,3 +215,27 @@ resource "google_cloud_run_v2_service_iam_member" "fe_trigger_can_view_backend" role = "roles/run.viewer" member = "serviceAccount:${module.frontend_service.trigger_sa_email}" } + +module "iap_load_balancer" { + count = var.iap_oauth2_client_id != "" ? 1 : 0 + source = "../iap-load-balancer" + + gcp_project_id = var.gcp_project_id + gcp_region = var.gcp_region + backend_service_name = var.backend_service_name + frontend_service_name = var.frontend_service_name + org_id = var.org_id + entra_client_id = var.entra_client_id + entra_tenant_id = var.entra_tenant_id + entra_client_secret = var.entra_client_secret + iap_oauth2_client_id = var.iap_oauth2_client_id + iap_oauth2_client_secret = var.iap_oauth2_client_secret + domain_name = var.domain_name + iap_access_members = var.iap_access_members + workforce_pool_id = var.workforce_pool_id +} + + + + + diff --git a/infra/modules/platform/outputs.tf b/infra/modules/platform/outputs.tf index 71b60d48..ec531df3 100644 --- a/infra/modules/platform/outputs.tf +++ b/infra/modules/platform/outputs.tf @@ -21,10 +21,21 @@ output "backend_service_url" { output "frontend_service_url" { description = "The URL of the deployed frontend service." - value = module.frontend_service.url + value = module.frontend_service.service_url } output "cloud_sql_connection_name" { description = "The connection name of the Cloud SQL instance to be used by the bootstrap script." value = module.postgresql.connection_name } + +output "load_balancer_ip" { + value = var.iap_oauth2_client_id != "" ? module.iap_load_balancer[0].load_balancer_ip : "" + description = "The external IP address of the Global HTTP(S) Load Balancer." +} + +output "iap_expected_audience" { + value = var.iap_oauth2_client_id != "" ? module.iap_load_balancer[0].iap_expected_audience : "" + description = "The expected Audience (aud) claim for IAP JWT validation." +} + diff --git a/infra/modules/platform/variables.tf b/infra/modules/platform/variables.tf index 38481ba8..eee4466c 100644 --- a/infra/modules/platform/variables.tf +++ b/infra/modules/platform/variables.tf @@ -87,3 +87,68 @@ variable "backend_runtime_secrets" { description = "Secrets to mount in the backend container at runtime." default = {} } + +variable "iap_expected_audience" { + type = string + description = "The expected Audience (aud) claim for Identity-Aware Proxy (IAP) JWT validation." +} + +variable "org_id" { + type = string + description = "The Organization ID for the Workforce Identity Pool" + default = "" +} + +variable "entra_client_id" { + type = string + description = "Microsoft Entra Client ID for Workforce Identity Federation" + default = "" +} + +variable "entra_tenant_id" { + type = string + description = "Microsoft Entra Tenant ID for Workforce Identity Federation" + default = "" +} + +variable "entra_client_secret" { + type = string + description = "Microsoft Entra Client Secret for Workforce Identity Federation" + default = "" + sensitive = true +} + + +variable "iap_oauth2_client_id" { + type = string + description = "OAuth2 Client ID for Identity-Aware Proxy" + default = "" +} + +variable "iap_oauth2_client_secret" { + type = string + description = "OAuth2 Client Secret for Identity-Aware Proxy" + default = "" + sensitive = true +} + +variable "domain_name" { + type = string + description = "The domain name for the Load Balancer Managed SSL Certificate" + default = "" +} + +variable "iap_access_members" { + type = list(string) + description = "The list of IAM members allowed to access the application via IAP." + default = [] +} + +variable "workforce_pool_id" { + type = string + description = "An existing Workforce Identity Pool ID (e.g. cs-workforce-pool)." + default = "" +} + + +