diff --git a/docs/GOOGLE_SECRET_MANAGER_SETUP.md b/docs/GOOGLE_SECRET_MANAGER_SETUP.md new file mode 100644 index 0000000..f02853c --- /dev/null +++ b/docs/GOOGLE_SECRET_MANAGER_SETUP.md @@ -0,0 +1,277 @@ +# Google Secret Manager Integration Guide + +This guide explains how to integrate Google Cloud Secret Manager with your Node.js application to securely manage environment variables. + +## Overview + +The Google Secret Manager integration allows you to: +- Store sensitive configuration values (passwords, API keys, tokens) in Google Cloud Secret Manager +- Load secrets at application startup and merge them with your existing environment variables +- Maintain backward compatibility with `.env` files for local development +- Use minimal code changes - existing `process.env.VARIABLE_NAME` access continues to work + +## Prerequisites + +1. **Google Cloud Project** with Secret Manager API enabled +2. **Service Account** with Secret Manager Secret Accessor role +3. **Authentication** - Service account credentials configured (via `GCP_KEY_FILE` or default credentials) + +## Setup Instructions + +### 1. Enable Secret Manager API + +```bash +gcloud services enable secretmanager.googleapis.com --project=YOUR_PROJECT_ID +``` + +### 2. Create Secrets in Google Secret Manager + +Create secrets using the Google Cloud Console or gcloud CLI: + +```bash +# Example: Create a database password secret +echo -n "your-secure-password" | gcloud secrets create DB_PASSWORD \ + --project=YOUR_PROJECT_ID \ + --data-file=- + +# Example: Create a JWT secret +echo -n "your-jwt-secret-key" | gcloud secrets create JWT_SECRET \ + --project=YOUR_PROJECT_ID \ + --data-file=- + +# Grant service account access to secrets +gcloud secrets add-iam-policy-binding DB_PASSWORD \ + --member="serviceAccount:YOUR_SERVICE_ACCOUNT@YOUR_PROJECT.iam.gserviceaccount.com" \ + --role="roles/secretmanager.secretAccessor" \ + --project=YOUR_PROJECT_ID +``` + +### 3. Configure Environment Variables + +Add the following to your `.env` file: + +```env +# Google Secret Manager Configuration +USE_GOOGLE_SECRET_MANAGER=true +GCP_PROJECT_ID=your-project-id + +# Optional: Prefix for all secret names (e.g., "prod" -> looks for "prod-DB_PASSWORD") +GCP_SECRET_PREFIX= + +# Optional: JSON file mapping secret names to env var names +GCP_SECRET_MAP_FILE=./secret-map.json +``` + +**Important Notes:** +- Set `USE_GOOGLE_SECRET_MANAGER=true` to enable the integration +- `GCP_PROJECT_ID` must be set (same as used for GCS/Vertex AI) +- `GCP_KEY_FILE` should already be configured for other GCP services +- When `USE_GOOGLE_SECRET_MANAGER=false` or not set, the app uses `.env` file only + +### 4. Secret Name Mapping + +By default, secrets in Google Secret Manager are automatically mapped to environment variables: +- Secret name: `DB_PASSWORD` → Environment variable: `DB_PASSWORD` +- Secret name: `db-password` → Environment variable: `DB_PASSWORD` (hyphens converted to underscores, uppercase) +- Secret name: `jwt-secret-key` → Environment variable: `JWT_SECRET_KEY` + +#### Custom Mapping (Optional) + +If you need custom mappings, create a JSON file (e.g., `secret-map.json`): + +```json +{ + "db-password-prod": "DB_PASSWORD", + "jwt-secret-key": "JWT_SECRET", + "okta-client-secret-prod": "OKTA_CLIENT_SECRET" +} +``` + +Then set in `.env`: +```env +GCP_SECRET_MAP_FILE=./secret-map.json +``` + +### 5. Secret Prefix (Optional) + +If all your secrets share a common prefix: + +```env +GCP_SECRET_PREFIX=prod +``` + +This will look for secrets named `prod-DB_PASSWORD`, `prod-JWT_SECRET`, etc. + +## How It Works + +1. **Application Startup:** + - `.env` file is loaded first (provides fallback values) + - If `USE_GOOGLE_SECRET_MANAGER=true`, secrets are fetched from Google Secret Manager + - Secrets are merged into `process.env`, overriding `.env` values if they exist + - Application continues with merged environment variables + +2. **Fallback Behavior:** + - If Secret Manager is disabled or fails, the app falls back to `.env` file + - No errors are thrown - the app continues with available configuration + - Logs indicate whether secrets were loaded successfully + +3. **Existing Code Compatibility:** + - No changes needed to existing code + - Continue using `process.env.VARIABLE_NAME` as before + - Secrets from GCS automatically populate `process.env` + +## Default Secrets Loaded + +The service automatically attempts to load these common secrets (if they exist in Secret Manager): + +**Database:** +- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` + +**Authentication:** +- `JWT_SECRET`, `REFRESH_TOKEN_SECRET`, `SESSION_SECRET` + +**SSO/Okta:** +- `OKTA_DOMAIN`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_API_TOKEN` + +**Email:** +- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD` + +**Web Push (VAPID):** +- `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` + +**Logging:** +- `LOKI_HOST`, `LOKI_USER`, `LOKI_PASSWORD` + +### Loading Custom Secrets + +To load additional secrets, modify the code: + +```typescript +// In server.ts or app.ts +import { googleSecretManager } from './services/googleSecretManager.service'; + +// Load default secrets + custom ones +await googleSecretManager.loadSecrets([ + 'DB_PASSWORD', + 'JWT_SECRET', + 'CUSTOM_API_KEY', // Your custom secret + 'CUSTOM_SECRET_2' +]); +``` + +Or load a single secret on-demand: + +```typescript +import { googleSecretManager } from './services/googleSecretManager.service'; + +const apiKey = await googleSecretManager.getSecretValue('CUSTOM_API_KEY', 'API_KEY'); +``` + +## Security Best Practices + +1. **Service Account Permissions:** + - Grant only `roles/secretmanager.secretAccessor` role + - Use separate service accounts for different environments + - Never grant `roles/owner` or `roles/editor` to service accounts + +2. **Secret Rotation:** + - Rotate secrets regularly in Google Secret Manager + - The app automatically uses the `latest` version of each secret + - No code changes needed when secrets are rotated + +3. **Environment Separation:** + - Use different Google Cloud projects for dev/staging/prod + - Use `GCP_SECRET_PREFIX` to namespace secrets by environment + - Never commit `.env` files with production secrets to version control + +4. **Access Control:** + - Use IAM policies to control who can read secrets + - Enable audit logging for secret access + - Regularly review secret access logs + +## Troubleshooting + +### Secrets Not Loading + +**Check logs for:** +``` +[Secret Manager] Google Secret Manager is disabled (USE_GOOGLE_SECRET_MANAGER != true) +[Secret Manager] GCP_PROJECT_ID not set, skipping Google Secret Manager +[Secret Manager] Failed to load secrets: [error message] +``` + +**Common issues:** +1. `USE_GOOGLE_SECRET_MANAGER` not set to `true` +2. `GCP_PROJECT_ID` not configured +3. Service account lacks Secret Manager permissions +4. Secrets don't exist in Secret Manager +5. Incorrect secret names (check case sensitivity) + +### Service Account Authentication + +Ensure service account credentials are available: +- Set `GCP_KEY_FILE` to point to service account JSON file +- Or configure Application Default Credentials (ADC) +- Test with: `gcloud auth application-default login` + +### Secret Not Found + +If a secret doesn't exist in Secret Manager: +- The app logs a debug message and continues +- Falls back to `.env` file value +- This is expected behavior - not all secrets need to be in GCS + +### Debugging + +Enable debug logging by setting: +```env +LOG_LEVEL=debug +``` + +This will show detailed logs about which secrets are being loaded. + +## Example Configuration + +**Local Development (.env):** +```env +USE_GOOGLE_SECRET_MANAGER=false +DB_PASSWORD=local-dev-password +JWT_SECRET=local-jwt-secret +``` + +**Production (.env):** +```env +USE_GOOGLE_SECRET_MANAGER=true +GCP_PROJECT_ID=re-platform-workflow-dealer +GCP_SECRET_PREFIX=prod +GCP_KEY_FILE=./credentials/service-account.json +# DB_PASSWORD and other secrets loaded from GCS +``` + +## Migration Strategy + +1. **Phase 1: Setup** + - Create secrets in Google Secret Manager + - Keep `.env` file with current values (as backup) + +2. **Phase 2: Test** + - Set `USE_GOOGLE_SECRET_MANAGER=true` in development + - Verify secrets are loaded correctly + - Test application functionality + +3. **Phase 3: Production** + - Deploy with `USE_GOOGLE_SECRET_MANAGER=true` + - Monitor logs for secret loading success + - Remove sensitive values from `.env` file (keep placeholders) + +4. **Phase 4: Cleanup** + - Remove production secrets from `.env` file + - Ensure all secrets are in Secret Manager + - Document secret names and mappings + +## Additional Resources + +- [Google Secret Manager Documentation](https://cloud.google.com/secret-manager/docs) +- [Secret Manager Client Library](https://cloud.google.com/nodejs/docs/reference/secret-manager/latest) +- [Service Account Best Practices](https://cloud.google.com/iam/docs/best-practices-service-accounts) + diff --git a/env.example b/env.example index 364789a..af8502b 100644 --- a/env.example +++ b/env.example @@ -30,6 +30,13 @@ GCP_PROJECT_ID=re-workflow-project GCP_BUCKET_NAME=re-workflow-documents GCP_KEY_FILE=./config/gcp-key.json +# Google Secret Manager (Optional - for production) +# Set USE_GOOGLE_SECRET_MANAGER=true to enable loading secrets from Google Secret Manager +# Secrets from GCS will override .env file values +USE_GOOGLE_SECRET_MANAGER=false +# GCP_SECRET_PREFIX=optional-prefix-for-secret-names (e.g., "prod" -> looks for "prod-DB_PASSWORD") +# GCP_SECRET_MAP_FILE=./secret-map.json (optional JSON file to map secret names to env var names) + # Email Service (Optional) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 diff --git a/package-lock.json b/package-lock.json index 5a7731b..424d9b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "re-workflow-backend", "version": "1.0.0", "dependencies": { + "@google-cloud/secret-manager": "^6.1.1", "@google-cloud/storage": "^7.18.0", "@google-cloud/vertexai": "^1.10.0", "@types/nodemailer": "^7.0.4", @@ -1614,6 +1615,18 @@ "node": ">=14" } }, + "node_modules/@google-cloud/secret-manager": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@google-cloud/secret-manager/-/secret-manager-6.1.1.tgz", + "integrity": "sha512-dwSuxJ9RNmAW46FjK1StiNIeOiSHHQs/XIy4VArJ6bBMR+WsIvR+zhPh2pa40aFa9uTty67j38Rl268TVV62EA==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@google-cloud/storage": { "version": "7.18.0", "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.18.0.tgz", @@ -1682,6 +1695,37 @@ "node": ">=18.0.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1744,7 +1788,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1762,7 +1805,6 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1775,7 +1817,6 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1788,14 +1829,12 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -1813,7 +1852,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1829,7 +1867,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -2302,6 +2339,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "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", @@ -2761,7 +2808,6 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "license": "MIT", "optional": true, "engines": { @@ -4348,7 +4394,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4358,7 +4403,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4629,7 +4673,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -4785,7 +4828,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -5095,7 +5137,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -5150,7 +5191,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5163,7 +5203,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/color-string": { @@ -5439,7 +5478,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -5450,6 +5488,15 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -5664,7 +5711,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -5741,7 +5787,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/enabled": { @@ -5903,7 +5948,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -6408,6 +6452,29 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6535,7 +6602,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -6552,7 +6618,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -6577,6 +6642,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/formidable": { "version": "3.5.4", "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", @@ -6717,7 +6794,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -6916,6 +6992,203 @@ "node": ">=14" } }, + "node_modules/google-gax": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.6.tgz", + "integrity": "sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "^10.1.0", + "google-logging-utils": "^1.1.1", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^3.0.0", + "protobufjs": "^7.5.3", + "retry-request": "^8.0.0", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/google-gax/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax/node_modules/retry-request": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz", + "integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/teeny-request": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz", + "integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/google-logging-utils": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", @@ -7334,7 +7607,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7395,7 +7667,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -7473,7 +7744,6 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -8360,6 +8630,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -8623,7 +8899,6 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -8648,7 +8923,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -8841,6 +9115,26 @@ "node": ">=6.0.0" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -9023,6 +9317,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -9174,7 +9477,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -9278,7 +9580,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9295,7 +9596,6 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -9312,7 +9612,6 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, "license": "ISC" }, "node_modules/path-to-regexp": { @@ -9682,6 +9981,18 @@ "dev": true, "license": "ISC" }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -9888,7 +10199,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10313,7 +10623,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10326,7 +10635,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10722,7 +11030,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10738,7 +11045,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10753,7 +11059,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -10767,7 +11072,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -11620,6 +11924,15 @@ "node": ">= 16" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -11640,7 +11953,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -11734,7 +12046,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -11753,7 +12064,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -11823,7 +12133,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -11840,7 +12149,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -11859,7 +12167,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 42309bd..5884ed3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "cleanup:dealer-claims": "ts-node -r tsconfig-paths/register src/scripts/cleanup-dealer-claims.ts" }, "dependencies": { + "@google-cloud/secret-manager": "^6.1.1", "@google-cloud/storage": "^7.18.0", "@google-cloud/vertexai": "^1.10.0", "@types/nodemailer": "^7.0.4", diff --git a/secret-map.example.json b/secret-map.example.json new file mode 100644 index 0000000..7475dbc --- /dev/null +++ b/secret-map.example.json @@ -0,0 +1,11 @@ +{ + "_comment": "Optional: Map Google Secret Manager secret names to environment variable names", + "_comment2": "If not provided, secrets are mapped automatically: secret-name -> SECRET_NAME (uppercase)", + + "examples": { + "db-password": "DB_PASSWORD", + "jwt-secret-key": "JWT_SECRET", + "okta-client-secret": "OKTA_CLIENT_SECRET" + } +} + diff --git a/src/app.ts b/src/app.ts index e221aae..2f4ebe0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,11 +10,24 @@ import { corsMiddleware } from './middlewares/cors.middleware'; import { metricsMiddleware, createMetricsRouter } from './middlewares/metrics.middleware'; import routes from './routes/index'; import { ensureUploadDir, UPLOAD_DIR } from './config/storage'; +import { initializeGoogleSecretManager } from './services/googleSecretManager.service'; import path from 'path'; -// Load environment variables +// Load environment variables from .env file first dotenv.config(); +// Initialize Google Secret Manager (async, but we'll wait for it in server.ts) +// This will merge secrets from GCS into process.env if USE_GOOGLE_SECRET_MANAGER=true +// Export initialization function so server.ts can await it before starting +export async function initializeSecrets(): Promise { + try { + await initializeGoogleSecretManager(); + } catch (error) { + // Log error but don't throw - allow fallback to .env + console.error('⚠️ Failed to initialize Google Secret Manager, using .env file:', error); + } +} + const app: express.Application = express(); const userService = new UserService(); diff --git a/src/config/sso.ts b/src/config/sso.ts index fb53a72..ab1e9a9 100644 --- a/src/config/sso.ts +++ b/src/config/sso.ts @@ -1,21 +1,25 @@ import { SSOConfig, SSOUserData } from '../types/auth.types'; +// Use getter functions to read from process.env dynamically +// This ensures values are read after secrets are loaded from Google Secret Manager const ssoConfig: SSOConfig = { - jwtSecret: process.env.JWT_SECRET || '', - jwtExpiry: process.env.JWT_EXPIRY || '24h', - refreshTokenExpiry: process.env.REFRESH_TOKEN_EXPIRY || '7d', - sessionSecret: process.env.SESSION_SECRET || '', + get jwtSecret() { return process.env.JWT_SECRET || ''; }, + get jwtExpiry() { return process.env.JWT_EXPIRY || '24h'; }, + get refreshTokenExpiry() { return process.env.REFRESH_TOKEN_EXPIRY || '7d'; }, + get sessionSecret() { return process.env.SESSION_SECRET || ''; }, // Use only FRONTEND_URL from environment - no fallbacks - allowedOrigins: process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || [], + get allowedOrigins() { + return process.env.FRONTEND_URL?.split(',').map(s => s.trim()).filter(Boolean) || []; + }, // Okta/Auth0 configuration for token exchange - oktaDomain: process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com', - oktaClientId: process.env.OKTA_CLIENT_ID || '', - oktaClientSecret: process.env.OKTA_CLIENT_SECRET || '', - oktaApiToken: process.env.OKTA_API_TOKEN || '', // SSWS token for Users API + get oktaDomain() { return process.env.OKTA_DOMAIN || 'https://dev-830839.oktapreview.com'; }, + get oktaClientId() { return process.env.OKTA_CLIENT_ID || ''; }, + get oktaClientSecret() { return process.env.OKTA_CLIENT_SECRET || ''; }, + get oktaApiToken() { return process.env.OKTA_API_TOKEN || ''; }, // SSWS token for Users API // Tanflow configuration for token exchange - tanflowBaseUrl: process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE', - tanflowClientId: process.env.TANFLOW_CLIENT_ID || 'REFLOW', - tanflowClientSecret: process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox', + get tanflowBaseUrl() { return process.env.TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE'; }, + get tanflowClientId() { return process.env.TANFLOW_CLIENT_ID || 'REFLOW'; }, + get tanflowClientSecret() { return process.env.TANFLOW_CLIENT_SECRET || 'cfIzMlwAMF1m4QWAP5StzZbV47HIrCox'; }, }; export { ssoConfig }; diff --git a/src/controllers/document.controller.ts b/src/controllers/document.controller.ts index b405beb..8a91708 100644 --- a/src/controllers/document.controller.ts +++ b/src/controllers/document.controller.ts @@ -133,16 +133,84 @@ export class DocumentController { } } - const doc = await Document.create({ + // Check if storageUrl exceeds database column limit (500 chars) + // GCS signed URLs can be very long (500-1000+ chars) + const MAX_STORAGE_URL_LENGTH = 500; + let finalStorageUrl = storageUrl; + if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) { + logWithContext('warn', 'Storage URL exceeds database column limit, truncating', { + originalLength: storageUrl.length, + maxLength: MAX_STORAGE_URL_LENGTH, + urlPrefix: storageUrl.substring(0, 100), + }); + // For signed URLs, we can't truncate as it will break the URL + // Instead, store null and generate signed URLs on-demand when needed + // The filePath is sufficient to generate a new signed URL later + finalStorageUrl = null as any; + logWithContext('info', 'Storing null storageUrl - will generate signed URL on-demand', { + filePath: gcsFilePath, + reason: 'Signed URL too long for database column', + }); + } + + // Truncate file names if they exceed database column limits (255 chars) + const MAX_FILE_NAME_LENGTH = 255; + const originalFileName = file.originalname; + let truncatedOriginalFileName = originalFileName; + + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { + // Preserve file extension when truncating + const ext = path.extname(originalFileName); + const nameWithoutExt = path.basename(originalFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + // If extension itself is too long, just use the extension + truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logWithContext('warn', 'File name truncated to fit database column', { + originalLength: originalFileName.length, + truncatedLength: truncatedOriginalFileName.length, + originalName: originalFileName.substring(0, 100) + '...', + truncatedName: truncatedOriginalFileName, + }); + } + + // Generate fileName (basename of the generated file name in GCS) + const generatedFileName = path.basename(gcsFilePath); + let truncatedFileName = generatedFileName; + + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { + const ext = path.extname(generatedFileName); + const nameWithoutExt = path.basename(generatedFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logWithContext('warn', 'Generated file name truncated', { + originalLength: generatedFileName.length, + truncatedLength: truncatedFileName.length, + }); + } + + // Prepare document data + const documentData = { requestId, uploadedBy: userId, - fileName: path.basename(file.filename || file.originalname), - originalFileName: file.originalname, + fileName: truncatedFileName, + originalFileName: truncatedOriginalFileName, fileType: extension, fileExtension: extension, fileSize: file.size, filePath: gcsFilePath, // Store GCS path or local path - storageUrl: storageUrl, // Store GCS URL or local URL + storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) mimeType: file.mimetype, checksum, isGoogleDoc: false, @@ -152,7 +220,43 @@ export class DocumentController { parentDocumentId: null as any, isDeleted: false, downloadCount: 0, - } as any); + }; + + logWithContext('info', 'Creating document record', { + requestId, + userId, + fileName: file.originalname, + filePath: gcsFilePath, + storageUrl: storageUrl, + documentData: JSON.stringify(documentData, null, 2), + }); + + let doc; + try { + doc = await Document.create(documentData as any); + logWithContext('info', 'Document record created successfully', { + documentId: doc.documentId, + requestId, + fileName: file.originalname, + }); + } catch (createError) { + const createErrorMessage = createError instanceof Error ? createError.message : 'Unknown error'; + const createErrorStack = createError instanceof Error ? createError.stack : undefined; + // Check if it's a Sequelize validation error + const sequelizeError = (createError as any)?.errors || (createError as any)?.parent; + logWithContext('error', 'Document.create() failed', { + error: createErrorMessage, + stack: createErrorStack, + sequelizeErrors: sequelizeError, + requestId, + userId, + fileName: file.originalname, + filePath: gcsFilePath, + storageUrl: storageUrl, + documentData: JSON.stringify(documentData, null, 2), + }); + throw createError; // Re-throw to be caught by outer catch block + } // Log document upload event logDocumentEvent('uploaded', doc.documentId, { diff --git a/src/controllers/workflow.controller.ts b/src/controllers/workflow.controller.ts index 83cb9bc..7ae9d60 100644 --- a/src/controllers/workflow.controller.ts +++ b/src/controllers/workflow.controller.ts @@ -279,27 +279,114 @@ export class WorkflowController { } } - const doc = await Document.create({ - requestId: workflow.requestId, - uploadedBy: userId, - fileName: path.basename(file.filename || file.originalname), - originalFileName: file.originalname, - fileType: extension, - fileExtension: extension, - fileSize: file.size, - filePath: gcsFilePath, // Store GCS path or local path - storageUrl: storageUrl, // Store GCS URL or local URL - mimeType: file.mimetype, - checksum, - isGoogleDoc: false, - googleDocUrl: null as any, - category: category || 'OTHER', - version: 1, - parentDocumentId: null as any, - isDeleted: false, - downloadCount: 0, - } as any); - docs.push(doc); + // Truncate file names if they exceed database column limits (255 chars) + const MAX_FILE_NAME_LENGTH = 255; + const originalFileName = file.originalname; + let truncatedOriginalFileName = originalFileName; + + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { + // Preserve file extension when truncating + const ext = path.extname(originalFileName); + const nameWithoutExt = path.basename(originalFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + // If extension itself is too long, just use the extension + truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] File name truncated to fit database column', { + originalLength: originalFileName.length, + truncatedLength: truncatedOriginalFileName.length, + originalName: originalFileName.substring(0, 100) + '...', + truncatedName: truncatedOriginalFileName, + }); + } + + // Generate fileName (basename of the generated file name in GCS) + const generatedFileName = path.basename(gcsFilePath); + let truncatedFileName = generatedFileName; + + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { + const ext = path.extname(generatedFileName); + const nameWithoutExt = path.basename(generatedFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] Generated file name truncated', { + originalLength: generatedFileName.length, + truncatedLength: truncatedFileName.length, + }); + } + + // Check if storageUrl exceeds database column limit (500 chars) + const MAX_STORAGE_URL_LENGTH = 500; + let finalStorageUrl = storageUrl; + if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) { + logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', { + originalLength: storageUrl.length, + maxLength: MAX_STORAGE_URL_LENGTH, + urlPrefix: storageUrl.substring(0, 100), + filePath: gcsFilePath, + }); + // For signed URLs, store null and generate on-demand later + finalStorageUrl = null as any; + } + + logger.info('[Workflow] Creating document record', { + fileName: truncatedOriginalFileName, + filePath: gcsFilePath, + storageUrl: finalStorageUrl ? 'present' : 'null (too long)', + requestId: workflow.requestId + }); + + try { + const doc = await Document.create({ + requestId: workflow.requestId, + uploadedBy: userId, + fileName: truncatedFileName, + originalFileName: truncatedOriginalFileName, + fileType: extension, + fileExtension: extension, + fileSize: file.size, + filePath: gcsFilePath, // Store GCS path or local path + storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) + mimeType: file.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: category || 'OTHER', + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + docs.push(doc); + logger.info('[Workflow] Document record created successfully', { + documentId: doc.documentId, + fileName: file.originalname, + }); + } catch (docError) { + const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error'; + const docErrorStack = docError instanceof Error ? docError.stack : undefined; + logger.error('[Workflow] Failed to create document record', { + error: docErrorMessage, + stack: docErrorStack, + fileName: file.originalname, + requestId: workflow.requestId, + filePath: gcsFilePath, + storageUrl: storageUrl, + }); + // Re-throw to be caught by outer catch block + throw docError; + } // Log document upload activity const requestMeta = getRequestMetadata(req); @@ -320,6 +407,13 @@ export class WorkflowController { ResponseHandler.success(res, { requestId: workflow.requestId, documents: docs }, 'Workflow created with documents', 201); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + logger.error('[WorkflowController] createWorkflowMultipart failed', { + error: errorMessage, + stack: errorStack, + userId: req.user?.userId, + filesCount: (req as any).files?.length || 0, + }); ResponseHandler.error(res, 'Failed to create workflow', 400, errorMessage); } } @@ -592,10 +686,27 @@ export class WorkflowController { } // Update workflow - const workflow = await workflowService.updateWorkflow(id, updateData); - if (!workflow) { - ResponseHandler.notFound(res, 'Workflow not found'); - return; + let workflow; + try { + workflow = await workflowService.updateWorkflow(id, updateData); + if (!workflow) { + ResponseHandler.notFound(res, 'Workflow not found'); + return; + } + logger.info('[WorkflowController] Workflow updated successfully', { + requestId: id, + workflowId: (workflow as any).requestId, + }); + } catch (updateError) { + const updateErrorMessage = updateError instanceof Error ? updateError.message : 'Unknown error'; + const updateErrorStack = updateError instanceof Error ? updateError.stack : undefined; + logger.error('[WorkflowController] updateWorkflow failed', { + error: updateErrorMessage, + stack: updateErrorStack, + requestId: id, + updateData: JSON.stringify(updateData, null, 2), + }); + throw updateError; // Re-throw to be caught by outer catch block } // Attach new files as documents @@ -632,40 +743,129 @@ export class WorkflowController { } } + // Truncate file names if they exceed database column limits (255 chars) + const MAX_FILE_NAME_LENGTH = 255; + const originalFileName = file.originalname; + let truncatedOriginalFileName = originalFileName; + + if (originalFileName.length > MAX_FILE_NAME_LENGTH) { + // Preserve file extension when truncating + const ext = path.extname(originalFileName); + const nameWithoutExt = path.basename(originalFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedOriginalFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + // If extension itself is too long, just use the extension + truncatedOriginalFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] File name truncated to fit database column', { + originalLength: originalFileName.length, + truncatedLength: truncatedOriginalFileName.length, + originalName: originalFileName.substring(0, 100) + '...', + truncatedName: truncatedOriginalFileName, + }); + } + + // Generate fileName (basename of the generated file name in GCS) + const generatedFileName = path.basename(gcsFilePath); + let truncatedFileName = generatedFileName; + + if (generatedFileName.length > MAX_FILE_NAME_LENGTH) { + const ext = path.extname(generatedFileName); + const nameWithoutExt = path.basename(generatedFileName, ext); + const maxNameLength = MAX_FILE_NAME_LENGTH - ext.length; + + if (maxNameLength > 0) { + truncatedFileName = nameWithoutExt.substring(0, maxNameLength) + ext; + } else { + truncatedFileName = ext.substring(0, MAX_FILE_NAME_LENGTH); + } + + logger.warn('[Workflow] Generated file name truncated', { + originalLength: generatedFileName.length, + truncatedLength: truncatedFileName.length, + }); + } + + // Check if storageUrl exceeds database column limit (500 chars) + const MAX_STORAGE_URL_LENGTH = 500; + let finalStorageUrl = storageUrl; + if (storageUrl && storageUrl.length > MAX_STORAGE_URL_LENGTH) { + logger.warn('[Workflow] Storage URL exceeds database column limit, storing null', { + originalLength: storageUrl.length, + maxLength: MAX_STORAGE_URL_LENGTH, + urlPrefix: storageUrl.substring(0, 100), + filePath: gcsFilePath, + }); + // For signed URLs, store null and generate on-demand later + finalStorageUrl = null as any; + } + logger.info('[Workflow] Creating document record', { - fileName: file.originalname, + fileName: truncatedOriginalFileName, filePath: gcsFilePath, - storageUrl: storageUrl, + storageUrl: finalStorageUrl ? 'present' : 'null (too long)', requestId: actualRequestId }); - const doc = await Document.create({ - requestId: actualRequestId, - uploadedBy: userId, - fileName: path.basename(file.filename || file.originalname), - originalFileName: file.originalname, - fileType: extension, - fileExtension: extension, - fileSize: file.size, - filePath: gcsFilePath, // Store GCS path or local path - storageUrl: storageUrl, // Store GCS URL or local URL - mimeType: file.mimetype, - checksum, - isGoogleDoc: false, - googleDocUrl: null as any, - category: category || 'OTHER', - version: 1, - parentDocumentId: null as any, - isDeleted: false, - downloadCount: 0, - } as any); - docs.push(doc); + try { + const doc = await Document.create({ + requestId: actualRequestId, + uploadedBy: userId, + fileName: truncatedFileName, + originalFileName: truncatedOriginalFileName, + fileType: extension, + fileExtension: extension, + fileSize: file.size, + filePath: gcsFilePath, // Store GCS path or local path + storageUrl: finalStorageUrl, // Store GCS URL or local URL (null if too long) + mimeType: file.mimetype, + checksum, + isGoogleDoc: false, + googleDocUrl: null as any, + category: category || 'OTHER', + version: 1, + parentDocumentId: null as any, + isDeleted: false, + downloadCount: 0, + } as any); + docs.push(doc); + logger.info('[Workflow] Document record created successfully', { + documentId: doc.documentId, + fileName: file.originalname, + }); + } catch (docError) { + const docErrorMessage = docError instanceof Error ? docError.message : 'Unknown error'; + const docErrorStack = docError instanceof Error ? docError.stack : undefined; + logger.error('[Workflow] Failed to create document record', { + error: docErrorMessage, + stack: docErrorStack, + fileName: file.originalname, + requestId: actualRequestId, + filePath: gcsFilePath, + storageUrl: storageUrl, + }); + // Continue with other files, but log the error + // Don't throw here - let the workflow update complete + } } } ResponseHandler.success(res, { workflow, newDocuments: docs }, 'Workflow updated with documents', 200); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const errorStack = error instanceof Error ? error.stack : undefined; + logger.error('[WorkflowController] updateWorkflowMultipart failed', { + error: errorMessage, + stack: errorStack, + requestId: req.params.id, + userId: req.user?.userId, + hasFiles: !!(req as any).files && (req as any).files.length > 0, + fileCount: (req as any).files ? (req as any).files.length : 0, + }); ResponseHandler.error(res, 'Failed to update workflow', 400, errorMessage); } } diff --git a/src/emailtemplates/approvalConfirmation.template.ts b/src/emailtemplates/approvalConfirmation.template.ts index d17e25b..c3d0c59 100644 --- a/src/emailtemplates/approvalConfirmation.template.ts +++ b/src/emailtemplates/approvalConfirmation.template.ts @@ -3,7 +3,7 @@ */ import { ApprovalConfirmationData } from './types'; -import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles } from './helpers'; +import { getEmailFooter, getEmailHeader, HeaderStyles, getNextStepsSection, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers'; import { getBrandedHeader } from './branding.config'; export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): string { @@ -21,21 +21,24 @@ export function getApprovalConfirmationEmail(data: ApprovalConfirmationData): st - + + + Request Approved + ${getResponsiveStyles()}
- +
${getEmailHeader(getBrandedHeader({ title: 'Request Approved', ...HeaderStyles.success }))} -