diff --git a/.env.example b/.env.example index 7c76b6c..e1e0a8e 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,9 @@ AI_SERVICE_URL=http://localhost:8000 AI_SERVICE_PORT=8000 # Used by the ai-service for CORS allow_origins (must match the running core URL) CORE_API_URL=http://localhost:3001 +# Shared secret for core→AI service calls. In Azure, generated by seed-keyvault.sh and +# injected from Key Vault. Leave empty for local dev (check is skipped when unset). +INTERNAL_SERVICE_TOKEN= # --- Marketplace --- MARKETPLACE_URL=https://marketplace.agentbase.dev/api/v1 @@ -79,6 +82,12 @@ STRIPE_WEBHOOK_SECRET= # Must use NEXT_PUBLIC_ prefix so Next.js exposes it to the browser NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +# --- Analytics (consent-gated — injected only after cookie opt-in) --- +# GA4 measurement ID (format: G-XXXXXXXXXX). Leave empty to disable GA4. +NEXT_PUBLIC_GA_MEASUREMENT_ID= +# Microsoft UET tag ID for Microsoft Advertising. Leave empty to disable UET. +NEXT_PUBLIC_MS_UET_TAG_ID= + # --- Email (optional) --- SMTP_HOST= SMTP_PORT=587 diff --git a/README.md b/README.md index fc52771..a12d8ab 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ agentbase/ | **SQL Database** | PostgreSQL 16 | | **Document DB** | MongoDB 7 | | **Cache** | Redis 7 | -| **Infrastructure** | Docker, Nginx, DigitalOcean Kubernetes (DOKS) | +| **Infrastructure** | Docker · Azure App Service + Bicep IaC | | **License** | GPL-3.0 | ## Quick Start diff --git a/azure-pipelines/scripts/seed-keyvault.sh b/azure-pipelines/scripts/seed-keyvault.sh index 658334b..3112d19 100644 --- a/azure-pipelines/scripts/seed-keyvault.sh +++ b/azure-pipelines/scripts/seed-keyvault.sh @@ -73,6 +73,9 @@ ensure_secret jwt-secret "$(or_placeholder "${JWT_SECRET:-$(gen)}")" ensure_secret jwt-refresh-secret "$(or_placeholder "${JWT_REFRESH_SECRET:-$(gen)}")" ensure_secret encryption-key "$(or_placeholder "${ENCRYPTION_KEY:-$(gen)}")" ensure_secret plugin-settings-encryption-key "$(or_placeholder "${PLUGIN_SETTINGS_ENCRYPTION_KEY:-$(gen)}")" +# Shared secret for core→AI service calls. Generated independently from JWT_SECRET; +# never derived from it. Rotate independently when needed. +ensure_secret internal-service-token "$(gen)" # --- Optional integration secrets (placeholder keeps the KV reference resolvable) --- set_secret stripe-secret-key "$(or_placeholder "${STRIPE_SECRET_KEY:-}")" diff --git a/docs/azure/architecture.md b/docs/azure/architecture.md index ea999cc..40b0063 100644 --- a/docs/azure/architecture.md +++ b/docs/azure/architecture.md @@ -64,11 +64,10 @@ graph LR user --> fe --> core dev --> core - core --> ai --> llm + core -->|X-Internal-Token| ai --> llm core --> data ai --> data core -. MARKETPLACE_URL .-> mkt - fe --> ai ``` The core platform connects to the Marketplace over `MARKETPLACE_URL` (dashed — @@ -109,11 +108,14 @@ graph TD ``` In **prod**, `networking.bicep` adds a VNet (app-integration subnet + private- -endpoint subnet), private endpoints for PostgreSQL, Cosmos, Redis, Blob and Key -Vault, and the matching private DNS zones — so the data tier has **no public -network access** (constitution Principle II). In **staging**, the data services -keep public access with an "allow Azure services" firewall rule to minimise cost -and complexity. +endpoint subnet), private endpoints for PostgreSQL, Cosmos, Redis, Blob, Key +Vault, **and the AI service App Service site** — so the data tier and the AI +service have **no public network access** (constitution Principle II). The AI +service further restricts inbound to `snet-app` only via `ipSecurityRestrictions`. +In **staging**, the data services keep public access with an "allow Azure +services" firewall rule; the AI service is protected by the app-layer +`INTERNAL_SERVICE_TOKEN` only (network restriction deferred until VNet +integration is promoted to staging). --- @@ -166,17 +168,18 @@ Principles applied: |------------------|-----------------------|-------------|--------| | `postgres-password` | `POSTGRES_PASSWORD` | core | variable group | | `mongo-uri` | `MONGO_URI` | core, ai | `az cosmosdb keys list` | -| `redis-password` | `REDIS_PASSWORD` | core¹ | `az redis list-keys` | +| `redis-password` | `REDIS_PASSWORD` | core | `az redis list-keys` | | `jwt-secret`, `jwt-refresh-secret` | `JWT_SECRET`, `JWT_REFRESH_SECRET` | core | generated once | | `encryption-key`, `plugin-settings-encryption-key` | same (upper-snake) | core | generated once | +| `internal-service-token` | `INTERNAL_SERVICE_TOKEN` | core + ai | generated once, **independent** from jwt-secret | | `stripe-secret-key`, `stripe-webhook-secret` | `STRIPE_*` | core | variable group (optional) | -| `openai-api-key`, `anthropic-api-key`, `gemini-api-key` | `*_API_KEY` | ai | variable group (optional) | +| `openai-api-key`, `anthropic-api-key`, `gemini-api-key`, `huggingface-api-key` | `*_API_KEY` | ai | variable group (optional) | -¹ Redis settings are injected and ready; the core's rate limiter is currently -in-memory (`common/interceptors/rate-limit.interceptor.ts`). Swapping it for a -Redis-backed limiter needs no infra change — `REDIS_HOST/PORT/TLS/PASSWORD` are -already present. Secrets are seeded idempotently by +Secrets are seeded idempotently by [`azure-pipelines/scripts/seed-keyvault.sh`](../../azure-pipelines/scripts/seed-keyvault.sh). +`internal-service-token` uses `ensure_secret` (generated once, never overwritten +automatically) and rotates independently from JWT keys — use different rotation +cadences and ownership. --- diff --git a/docs/azure/pipeline.md b/docs/azure/pipeline.md index bf29d9e..ab8ee17 100644 --- a/docs/azure/pipeline.md +++ b/docs/azure/pipeline.md @@ -19,13 +19,27 @@ az group create -n rg-agentbase-staging -l eastus az group create -n rg-agentbase-prod -l eastus ``` -### 1.2 Service connection +### 1.2 Service connections -Create an Azure Resource Manager **service connection** (Project Settings → -Service connections) scoped to the subscription, e.g. named -`agentbase-azure`. Grant its service principal **Contributor** + **User Access -Administrator** on both resource groups (User Access Administrator is required -because the Bicep creates **role assignments** in `rbac.bicep`). +Create **two** Azure Resource Manager service connections (Project Settings → +Service connections), one per environment. Scope each to its resource group only +(not the whole subscription) for least-privilege isolation. + +| ADO variable | Connection name (example) | Scoped to | +|---|---|---| +| `AZURE_SERVICE_CONNECTION_STAGING` | `agentbase-staging-sc` | `RG_STAGING` | +| `AZURE_SERVICE_CONNECTION_PROD` | `agentbase-prod-sc` | `RG_PROD` | + +Grant each service principal **two roles** on its resource group: +- **Owner** — required because `rbac.bicep` creates role assignments (Contributor + alone can't grant roles; you need User Access Administrator, which Owner includes). +- **Key Vault Secrets Officer** (at RG scope, not KV resource scope) — data-plane + secret writes. Scoped to the RG so the role inherits to the Key Vault once + Bicep creates it on the first run (the KV doesn't exist yet when this is set up). + +For the prod service connection: choose **"Specific pipelines"** rather than +"Grant access to all pipelines" in the security settings, and add only the +`agentbase-deploy.yml` pipeline. ### 1.3 Variable group @@ -34,7 +48,8 @@ Library) with: | Variable | Secret? | Example / purpose | |----------|:------:|-------------------| -| `AZURE_SERVICE_CONNECTION` | no | `agentbase-azure` | +| `AZURE_SERVICE_CONNECTION_STAGING` | no | `agentbase-staging-sc` | +| `AZURE_SERVICE_CONNECTION_PROD` | no | `agentbase-prod-sc` | | `RG_STAGING` | no | `rg-agentbase-staging` | | `RG_PROD` | no | `rg-agentbase-prod` | | `PG_ADMIN_PASSWORD` | **yes** | PostgreSQL admin password (≥ 12 chars, complex) | @@ -44,11 +59,13 @@ Library) with: | `OPENAI_API_KEY` | yes | *(optional)* AI provider | | `ANTHROPIC_API_KEY` | yes | *(optional)* | | `GEMINI_API_KEY` | yes | *(optional)* | +| `HUGGINGFACE_API_KEY` | yes | *(optional)* | Optional secrets left undefined are stored in Key Vault as `not-configured` placeholders so their Key Vault references still resolve. `jwt-secret`, -`jwt-refresh-secret`, `encryption-key`, and `plugin-settings-encryption-key` -are **generated once** by the seed script and preserved across deploys. +`jwt-refresh-secret`, `encryption-key`, `plugin-settings-encryption-key`, and +`internal-service-token` are **generated once** by the seed script and preserved +across deploys — do not add these to the variable group. ### 1.4 Environments + approval gate @@ -143,7 +160,79 @@ az group delete --name rg-agentbase-staging --yes --no-wait --- -## 6. Local validation (before pushing) +## 6. Prelaunch checklist + +**This checklist must be signed off before the first production push.** +Items marked **[GATE]** are hard blockers — the checklist cannot be signed +off while any GATE item is unresolved. No-go audit findings become known issues +that slip under launch pressure without explicit gates here. + +### Security + +- [ ] **[GATE]** `INTERNAL_SERVICE_TOKEN` is in Key Vault (`internal-service-token` + secret exists and is not `not-configured`) for both staging and prod. +- [ ] **[GATE]** AI service `/api/ai/conversations` returns 401 without the token; + returns 200 with the correct `X-Internal-Token` header. +- [ ] **[GATE]** Rate limiting enforced globally: verify with concurrent requests + across multiple instances that the Redis-backed counter triggers 429. +- [ ] **[GATE]** Encryption key present in Key Vault (`encryption-key`) and is a + 64-character hex string — test BYOK provider key save/load round-trip. +- [ ] All security audit categories in `docs/azure/prelaunch-security-audit.md` + show **GO**. + +### Network lockdown (prod) + +- [ ] **[GATE]** AI service not reachable from the public internet in prod. Test: + `curl https://.azurewebsites.net/api/ai/conversations` from + outside Azure — must return 403 or TCP connection refused (private endpoint). +- [ ] **[GATE]** Core→AI calls succeed through the VNet path in prod. +- [ ] AI service `ipSecurityRestrictions` applied: Azure portal → AI app → + Networking → Access Restrictions — only `snet-app` allow rule present. + +### SSE streaming + +- [ ] SSE stream completes normally end-to-end through the core proxy: + `curl -N https:///v1/chat -H 'X-API-Key: ' -d '{"message":"hello"}'` +- [ ] **[GATE — disconnect cleanup]** Manual verification: run the above curl, kill + it mid-stream with Ctrl-C, then check AI service logs for unclosed generator + errors. No `GeneratorExit` unhandled traces should appear. +- [ ] No response buffering: chunks arrive incrementally (not in one burst after + stream ends). If on App Service, confirm `X-Accel-Buffering: no` header + is present in the response. + +### Analytics / consent + +- [ ] Cookie consent banner appears on first visit (no prior localStorage entry). +- [ ] GA4 and UET scripts are **not** present in page source before consent is + given — verify with browser devtools network tab. +- [ ] After accepting consent, GA4/UET scripts load and fire pageview events. +- [ ] "Manage cookies" resets consent and banner reappears on reload. + +### Pipeline + +- [ ] `az bicep build --file infra/main.bicep` passes (no errors, warnings OK). +- [ ] Validate stage (`what-if`) completes green on a staging run. +- [ ] Staging deploy green with all three health checks passing. +- [ ] Manual approval gate active on `agentbase-prod` environment in ADO. +- [ ] Prod service connection uses "Specific pipelines" authorization. +- [ ] Rollback procedure tested: re-point an app at a previous tag and verify + it comes up healthy. + +### Sign-off + +| Area | Signed off by | Date | +| --- | --- | --- | +| Security | | | +| Network lockdown (prod) | | | +| SSE streaming | | | +| Analytics / consent | | | +| Pipeline | | | + +All GATE items resolved and all rows signed off before merging to production. + +--- + +## 7. Local validation (before pushing) ```bash az bicep build --file infra/main.bicep # lint diff --git a/docs/azure/prelaunch-security-audit.md b/docs/azure/prelaunch-security-audit.md new file mode 100644 index 0000000..989b1e3 --- /dev/null +++ b/docs/azure/prelaunch-security-audit.md @@ -0,0 +1,188 @@ +# Agentbase Prelaunch Security Audit + +Covers every category required by Workitem #7 Workstream 3/4. Each category +carries a **VERDICT** — `GO` or `NO-GO`. `NO-GO` items are hard blockers for +the Workstream 6 prelaunch checklist; they must be resolved before sign-off. + +--- + +## 1. Encryption Utility + +**File:** `packages/core/src/common/utils/encryption.util.ts` + +**Finding:** AES-256-GCM with proper 96-bit random IV and GCM authentication +tag. Key is a 32-byte value sourced from `ENCRYPTION_KEY` env var (in Azure: +Key Vault reference `encryption-key`). `getKey()` throws at call-time if the +env var is absent or wrong-length — no silent fallback to a weak key. +`decrypt()` calls `decipher.setAuthTag()` which causes an `ERR_CRYPTO_AUTH_TAG` +error on tamper, correctly preventing ciphertext substitution. + +**VERDICT: GO** — implementation is correct. + +--- + +## 2. Platform API Keys + +**File:** `packages/core/src/modules/api-keys/api-keys.service.ts` + +**Finding:** Keys are generated as `ab_<56 hex chars>` (224 bits of entropy), +stored as SHA-256 hash only (`keyHash`). The raw key is returned to the caller +exactly once and never persisted. Subsequent display uses `keyPrefix` (first +10 chars + ellipsis). The guard (`api-key.guard.ts`) rehashes the inbound key +and compares hashes — no timing-safe compare in place, but SHA-256 comparison +of fixed-length hex strings has negligible timing variance in practice. + +**VERDICT: GO** — storage and display model is correct. + +--- + +## 3. BYOK Provider Keys + +**File:** `packages/core/src/modules/provider-keys/provider-keys.service.ts` + +**Finding:** User-supplied AI provider keys are encrypted at rest using +`encryption.util.ts` (`encrypt(dto.apiKey)` on save). Only the last-4 chars +are returned to the client for display. `getDecryptedKey()` is marked internal +and its comment explicitly states the result must never be logged or returned to +clients. The decrypted key is passed to the AI service in an internal request +body (`api_key` field) — it travels only on the server-side core→AI path, never +to the browser. + +**VERDICT: GO** — encrypted at rest, redacted in responses, internal-only path. + +--- + +## 4. Log Redaction + +**Files searched:** all `logger.*` / `this.logger.*` / `console.*` calls in +`packages/core/src/` + +**Finding:** Structured logging uses `nestjs-pino` / pino. No call sites were +found logging raw `apiKey`, `password`, `token`, `secret`, or `encryption` values. +The `serializers` in `AppModule` strip request/response bodies from auto-logging. +Pino's default serializer does not traverse nested object fields. + +**Risk note:** If a future developer logs `req.body` or spreads a DTO containing +a key field, the structured log would include it. Consider adding a pino +`redact` configuration for common secret field names as a belt-and-suspenders +control. + +**VERDICT: GO** — no current leakage found; add pino redact as follow-up. + +--- + +## 5. Rate Limiting on Public API Endpoints + +**Files:** `packages/core/src/common/interceptors/rate-limit.interceptor.ts` + `packages/core/src/modules/applications/public-api.controller.ts` + +**Finding (as of this PR):** `RateLimitInterceptor` has been rewritten to use a +Redis-backed fixed-window counter (`INCR` + `EXPIRE`) via `RedisService`. The +limit is enforced globally across all App Service instances, not per-instance. +Under a Redis outage the interceptor fails open (no limit enforced) and falls +back to a per-instance in-memory counter — this is the safer choice to avoid +blocking legitimate traffic during infrastructure failure. + +The public API controller applies `@UseInterceptors(RateLimitInterceptor)` at +the class level, covering `/v1/chat`, `/v1/app`, `/v1/app/:slug`, and +`/v1/conversations/:id`. + +**VERDICT: GO** — Redis-backed rate limiting is in place. Monitor Redis +availability; consider alerting on `rl:*` key volume spikes as an abuse signal. + +--- + +## 6. Key Vault References & RBAC + +**Files:** `infra/main.bicep`, `infra/modules/rbac.bicep` + +**Finding:** All secrets (JWT, encryption keys, DB passwords, AI provider keys, +`INTERNAL_SERVICE_TOKEN`) are stored in Key Vault and injected into App Service +via KV references (`@Microsoft.KeyVault(SecretUri=...)`). Apps read them at +startup using their system-assigned managed identity. + +RBAC per identity (from `rbac.bicep`): +- **core:** AcrPull + Key Vault Secrets User + Storage Blob Data Contributor +- **frontend:** AcrPull only — no KV access (no secrets needed at runtime) +- **ai-service:** AcrPull + Key Vault Secrets User + +The pipeline's service principal has Owner + Key Vault Secrets Officer scoped +to the resource group (documented in `.claude/ADO_VARIABLES.md`). + +**VERDICT: GO** — least-privilege correctly applied. + +--- + +## 7. CORS & Security Headers + +**Core (`packages/core/src/main.ts`):** Helmet is imported (`helmet`) providing +HSTS, X-Content-Type-Options, X-Frame-Options, and other headers. CORS origin +allowlist should be verified in `main.ts` against `FRONTEND_URL` only. + +**AI service (`packages/ai-service/app/main.py`):** CORS `allow_origins` was +previously `[FRONTEND_URL, CORE_API_URL]`. As of this PR it is tightened to +`[CORE_API_URL]` only — the browser never calls the AI service directly. + +**App Service:** `httpsOnly: true` in Bicep enforces HTTPS (HSTS via Azure +redirect). TLS 1.2 minimum enforced (`minTlsVersion: '1.2'`). + +**VERDICT: GO** — verify `main.ts` CORS config lists only `FRONTEND_URL` before +prod deploy. + +--- + +## 8. Service-to-Service Authentication + +**Status (as of this PR):** `INTERNAL_SERVICE_TOKEN` is now seeded into Key +Vault (`ensure_secret internal-service-token`), injected into both `coreApp` +and `aiApp` via KV reference, validated by FastAPI middleware on all +`/api/ai/*` routes, and sent as `X-Internal-Token` on every core→AI fetch. + +The token is independently generated (`openssl rand -hex 32`), not derived from +`JWT_SECRET` or any other key, and rotates independently. + +Network-layer restriction (Workstream 1B) is a separate step that follows after +app-layer token verification is confirmed working in staging. + +**VERDICT: GO** + +--- + +## 9. Authentication Architecture (Workstream 4) + +**Agreed architecture — intentional design, not a gap:** + +| Layer | Mechanism | Scope | +|---|---|---| +| User identity (browser) | NestJS JWT (HS256) + OAuth2 (GitHub/Google) | All user-facing API routes | +| Service-to-service | `X-Internal-Token` shared secret | core → AI service | +| Secret access | Azure Managed Identity → Key Vault | App Service → Key Vault | +| Image pull | Azure Managed Identity → ACR | App Service → ACR | + +**App Service Authentication (Easy Auth) is intentionally OFF.** Azure Easy Auth +is designed for adding OAuth in front of apps that have no auth layer. Agentbase +has its own full JWT/OAuth system; enabling Easy Auth on top would create two +competing identity layers. The correct place for NestJS JWT config (`JWT_SECRET`, +`JWT_EXPIRATION`) is Key Vault, which is already wired up. + +This decision is recorded here explicitly so it is not accidentally reversed +during an Azure portal review. The App Service "Authentication" blade should +show "Not configured" for all three app services. + +--- + +## Verdict Summary + +| Category | Verdict | Notes | +|---|---|---| +| Encryption utility | **GO** | AES-256-GCM + auth tag + random IV | +| Platform API keys | **GO** | SHA-256 hashed, shown once | +| BYOK provider keys | **GO** | Encrypted at rest, never returned | +| Log redaction | **GO** | No leakage found; add pino redact as follow-up | +| Rate limiting | **GO** | Redis-backed after this PR | +| KV references & RBAC | **GO** | Least-privilege per identity | +| CORS & headers | **GO** | Verify core CORS list before prod | +| Service-to-service auth | **GO** | INTERNAL_SERVICE_TOKEN wired end-to-end | +| Auth architecture | **GO** | Easy Auth intentionally off, documented | + +**All categories GO. No hard blockers for the Workstream 6 prelaunch checklist.** diff --git a/infra/main.bicep b/infra/main.bicep index a1454b9..899fe2b 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -229,6 +229,21 @@ module networking 'modules/networking.bicep' = if (deployPrivateNetworking) { // so the non-null assertion is safe. var appSubnetId = deployPrivateNetworking ? networking!.outputs.appSubnetId : '' +// In prod (deployPrivateNetworking=true), restrict AI service inbound to the core app's +// VNet integration subnet only. Staging relies on the app-layer INTERNAL_SERVICE_TOKEN +// (WS1A) until VNet integration is promoted to staging in a follow-up. +var aiIpRestrictions = deployPrivateNetworking + ? [ + { + name: 'allow-core-vnet' + priority: 100 + action: 'Allow' + vnetSubnetResourceId: networking!.outputs.appSubnetId + description: 'Allow inbound from App Service VNet integration subnet (core app only)' + } + ] + : [] + // ---------------------------------------------------------------------------- // Compute — App Service Plan + 3 container apps // ---------------------------------------------------------------------------- @@ -265,8 +280,9 @@ module coreApp 'modules/app-service-container.bicep' = { { name: 'AI_SERVICE_URL', value: 'https://${aiHost}' } { name: 'ENABLE_SWAGGER', value: 'false' } // Run pending TypeORM migrations on startup. Reaches the DB from inside Azure - // (works for prod's private network, where a pipeline agent cannot). Idempotent; - // pin to a single instance or add a migration lock before enabling autoscale. + // (works for prod's private network, where a pipeline agent cannot). Safe under + // autoscale: migrations run at bootstrap behind a Postgres advisory lock + // (packages/core/src/main.ts), so concurrent instances serialize the DDL. { name: 'RUN_MIGRATIONS', value: 'true' } { name: 'MARKETPLACE_URL', value: marketplaceUrl } // PostgreSQL @@ -299,6 +315,8 @@ module coreApp 'modules/app-service-container.bicep' = { // Payments { name: 'STRIPE_SECRET_KEY', value: kvRef(kvUri, 'stripe-secret-key') } { name: 'STRIPE_WEBHOOK_SECRET', value: kvRef(kvUri, 'stripe-webhook-secret') } + // Service-to-service auth: core presents this token to AI service on every internal call + { name: 'INTERNAL_SERVICE_TOKEN', value: kvRef(kvUri, 'internal-service-token') } ] } } @@ -336,6 +354,7 @@ module aiApp 'modules/app-service-container.bicep' = { appInsightsConnectionString: monitoring.outputs.connectionString healthCheckPath: '/api/ai/health' vnetSubnetId: appSubnetId + ipSecurityRestrictions: aiIpRestrictions appSettings: [ { name: 'MONGO_URI', value: kvRef(kvUri, 'mongo-uri') } { name: 'OPENAI_API_KEY', value: kvRef(kvUri, 'openai-api-key') } @@ -344,7 +363,10 @@ module aiApp 'modules/app-service-container.bicep' = { { name: 'HUGGINGFACE_API_KEY', value: kvRef(kvUri, 'huggingface-api-key') } // CORS allow_origins — must match the actual deployed URLs, not localhost defaults { name: 'CORE_API_URL', value: 'https://${coreHost}' } - { name: 'FRONTEND_URL', value: 'https://${frontendHost}' } + // FRONTEND_URL intentionally omitted — browser never calls AI service directly; + // CORS is restricted to CORE_API_URL only (see ai-service/app/main.py) + // Service-to-service auth: AI service validates this token on all /api/ai/* routes + { name: 'INTERNAL_SERVICE_TOKEN', value: kvRef(kvUri, 'internal-service-token') } ] } } @@ -387,6 +409,24 @@ module aiRbac 'modules/rbac.bicep' = { } } +// ---------------------------------------------------------------------------- +// AI service private endpoint (prod only) +// Deployed as a separate module after both aiApp and networking to avoid the +// circular dependency that would arise from passing aiApp.outputs.id into the +// networking module (which aiApp already depends on for its VNet subnet). +// ---------------------------------------------------------------------------- + +module aiServicePe 'modules/pe-ai-service.bicep' = if (deployPrivateNetworking) { + name: 'aiServicePe' + params: { + location: location + tags: tags + aiAppId: aiApp.outputs.id + peSubnetId: networking!.outputs.peSubnetId + vnetId: networking!.outputs.vnetId + } +} + // ---------------------------------------------------------------------------- // Outputs — consumed by agentbase-deploy.yml // ---------------------------------------------------------------------------- diff --git a/infra/main.json b/infra/main.json new file mode 100644 index 0000000..58a3770 --- /dev/null +++ b/infra/main.json @@ -0,0 +1,2815 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "languageVersion": "2.0", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "7871493666138067292" + } + }, + "functions": [ + { + "namespace": "__bicep", + "members": { + "kvRef": { + "parameters": [ + { + "type": "string", + "name": "vaultUri" + }, + { + "type": "string", + "name": "secretName" + } + ], + "output": { + "type": "string", + "value": "[format('@Microsoft.KeyVault(SecretUri={0}secrets/{1}/)', parameters('vaultUri'), parameters('secretName'))]" + } + } + } + } + ], + "parameters": { + "environment": { + "type": "string", + "allowedValues": [ + "staging", + "prod" + ], + "metadata": { + "description": "Deployment environment" + } + }, + "project": { + "type": "string", + "defaultValue": "agentbase", + "metadata": { + "description": "Project name used in resource naming" + } + }, + "owner": { + "type": "string", + "metadata": { + "description": "Owner tag — team or individual responsible for this deployment" + } + }, + "location": { + "type": "string", + "defaultValue": "[resourceGroup().location]", + "metadata": { + "description": "Azure region for all resources" + } + }, + "uniqueSuffix": { + "type": "string", + "defaultValue": "[take(uniqueString(resourceGroup().id), 5)]", + "metadata": { + "description": "Short suffix for globally-unique resource names (ACR, Key Vault, Storage, etc.)" + } + }, + "appServicePlanSku": { + "type": "string", + "defaultValue": "B2", + "metadata": { + "description": "App Service Plan SKU (B2 for staging, P1v2 for prod)" + } + }, + "containerImageTag": { + "type": "string", + "defaultValue": "latest", + "metadata": { + "description": "Container image tag to deploy (pipeline passes the build ID; defaults to latest)" + } + }, + "postgresAdminPassword": { + "type": "securestring", + "metadata": { + "description": "PostgreSQL administrator password (supplied by the pipeline from a masked variable group)" + } + }, + "deployPrivateNetworking": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Deploy VNet + private endpoints to lock the data tier off the public internet (prod)" + } + }, + "marketplaceUrl": { + "type": "string", + "defaultValue": "https://marketplace.agentbase.dev/api/v1", + "metadata": { + "description": "Marketplace API base URL the core platform connects to (separate deployment)" + } + } + }, + "variables": { + "tags": { + "environment": "[parameters('environment')]", + "project": "[parameters('project')]", + "owner": "[parameters('owner')]", + "managedBy": "bicep", + "workItem": "6" + }, + "nameBase": "[format('{0}-{1}', parameters('project'), parameters('environment'))]", + "compact": "[replace(parameters('project'), '-', '')]", + "envShort": "[take(parameters('environment'), 4)]", + "acrName": "[toLower(format('acr{0}{1}{2}', variables('compact'), parameters('environment'), parameters('uniqueSuffix')))]", + "keyVaultName": "[take(format('kv-{0}-{1}-{2}', parameters('project'), variables('envShort'), parameters('uniqueSuffix')), 24)]", + "storageName": "[take(toLower(format('st{0}{1}{2}', variables('compact'), variables('envShort'), parameters('uniqueSuffix'))), 24)]", + "postgresName": "[format('psql-{0}-{1}', variables('nameBase'), parameters('uniqueSuffix'))]", + "cosmosName": "[toLower(format('cosmos-{0}-{1}', variables('nameBase'), parameters('uniqueSuffix')))]", + "redisName": "[format('redis-{0}-{1}', variables('nameBase'), parameters('uniqueSuffix'))]", + "coreAppName": "[format('app-{0}-core-{1}-{2}', parameters('project'), parameters('environment'), parameters('uniqueSuffix'))]", + "frontendAppName": "[format('app-{0}-web-{1}-{2}', parameters('project'), parameters('environment'), parameters('uniqueSuffix'))]", + "aiAppName": "[format('app-{0}-ai-{1}-{2}', parameters('project'), parameters('environment'), parameters('uniqueSuffix'))]", + "coreHost": "[format('{0}.azurewebsites.net', variables('coreAppName'))]", + "frontendHost": "[format('{0}.azurewebsites.net', variables('frontendAppName'))]", + "aiHost": "[format('{0}.azurewebsites.net', variables('aiAppName'))]" + }, + "resources": { + "monitoring": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "monitoring", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('nameBase')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "18053915702657242726" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Base name for monitoring resources" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "dailyQuotaGb": { + "type": "int", + "defaultValue": 1, + "metadata": { + "description": "Daily ingestion cap in GB (cost guardrail)" + } + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2023-09-01", + "name": "[format('log-{0}', parameters('name'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30, + "workspaceCapping": { + "dailyQuotaGb": "[parameters('dailyQuotaGb')]" + }, + "features": { + "enableLogAccessUsingOnlyResourcePermissions": true + } + } + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('appi-{0}', parameters('name'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', parameters('name')))]", + "IngestionMode": "LogAnalytics", + "publicNetworkAccessForIngestion": "Enabled", + "publicNetworkAccessForQuery": "Enabled" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', parameters('name')))]" + ] + } + ], + "outputs": { + "connectionString": { + "type": "string", + "metadata": { + "description": "Application Insights connection string (injected into apps)" + }, + "value": "[reference(resourceId('Microsoft.Insights/components', format('appi-{0}', parameters('name'))), '2020-02-02').ConnectionString]" + }, + "logAnalyticsId": { + "type": "string", + "metadata": { + "description": "Log Analytics workspace resource ID (for diagnostic settings)" + }, + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-{0}', parameters('name')))]" + } + } + } + } + }, + "acr": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "acr", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('acrName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "14697103623950202448" + } + }, + "parameters": { + "name": { + "type": "string", + "minLength": 5, + "maxLength": 50, + "metadata": { + "description": "Globally-unique ACR name (alphanumeric only, 5-50 chars)" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + } + }, + "resources": [ + { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2023-11-01-preview", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Basic" + }, + "properties": { + "adminUserEnabled": false, + "publicNetworkAccess": "Enabled", + "anonymousPullEnabled": false + } + } + ], + "outputs": { + "id": { + "type": "string", + "metadata": { + "description": "ACR resource ID (for AcrPull role assignment)" + }, + "value": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('name'))]" + }, + "loginServer": { + "type": "string", + "metadata": { + "description": "ACR login server, e.g. acragentbase.azurecr.io" + }, + "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', parameters('name')), '2023-11-01-preview').loginServer]" + }, + "name": { + "type": "string", + "metadata": { + "description": "ACR name" + }, + "value": "[parameters('name')]" + } + } + } + } + }, + "keyVault": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "keyVault", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('keyVaultName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "disablePublicAccess": { + "value": "[parameters('deployPrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "5040030200854178326" + } + }, + "parameters": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 24, + "metadata": { + "description": "Globally-unique Key Vault name (3-24 chars, alphanumeric + hyphens)" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "disablePublicAccess": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Disable public network access (true in prod — reach via private endpoint)" + } + } + }, + "resources": [ + { + "type": "Microsoft.KeyVault/vaults", + "apiVersion": "2023-07-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "family": "A", + "name": "standard" + }, + "tenantId": "[subscription().tenantId]", + "enableRbacAuthorization": true, + "enableSoftDelete": true, + "softDeleteRetentionInDays": 7, + "enablePurgeProtection": true, + "publicNetworkAccess": "[if(parameters('disablePublicAccess'), 'Disabled', 'Enabled')]", + "networkAcls": { + "bypass": "AzureServices", + "defaultAction": "[if(parameters('disablePublicAccess'), 'Deny', 'Allow')]" + } + } + } + ], + "outputs": { + "uri": { + "type": "string", + "metadata": { + "description": "Key Vault URI (e.g. https://kv-agentbase.vault.azure.net/)" + }, + "value": "[reference(resourceId('Microsoft.KeyVault/vaults', parameters('name')), '2023-07-01').vaultUri]" + }, + "name": { + "type": "string", + "metadata": { + "description": "Key Vault name" + }, + "value": "[parameters('name')]" + }, + "id": { + "type": "string", + "metadata": { + "description": "Key Vault resource ID (for private endpoint)" + }, + "value": "[resourceId('Microsoft.KeyVault/vaults', parameters('name'))]" + } + } + } + } + }, + "storage": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "storage", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('storageName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "disablePublicAccess": { + "value": "[parameters('deployPrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "15585973143530782469" + } + }, + "parameters": { + "name": { + "type": "string", + "minLength": 3, + "maxLength": 24, + "metadata": { + "description": "Globally-unique storage account name (3-24 chars, lowercase alphanumeric)" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "uploadsContainerName": { + "type": "string", + "defaultValue": "uploads", + "metadata": { + "description": "Blob container name for application uploads" + } + }, + "disablePublicAccess": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Disable public network access (true in prod — reach via private endpoint)" + } + } + }, + "resources": [ + { + "type": "Microsoft.Storage/storageAccounts", + "apiVersion": "2023-05-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Standard_LRS" + }, + "kind": "StorageV2", + "properties": { + "accessTier": "Hot", + "supportsHttpsTrafficOnly": true, + "minimumTlsVersion": "TLS1_2", + "allowBlobPublicAccess": false, + "allowSharedKeyAccess": false, + "publicNetworkAccess": "[if(parameters('disablePublicAccess'), 'Disabled', 'Enabled')]", + "networkAcls": { + "bypass": "AzureServices", + "defaultAction": "[if(parameters('disablePublicAccess'), 'Deny', 'Allow')]" + }, + "encryption": { + "services": { + "blob": { + "enabled": true + } + }, + "keySource": "Microsoft.Storage" + } + } + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices", + "apiVersion": "2023-05-01", + "name": "[format('{0}/{1}', parameters('name'), 'default')]", + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts', parameters('name'))]" + ] + }, + { + "type": "Microsoft.Storage/storageAccounts/blobServices/containers", + "apiVersion": "2023-05-01", + "name": "[format('{0}/{1}/{2}', parameters('name'), 'default', parameters('uploadsContainerName'))]", + "properties": { + "publicAccess": "None" + }, + "dependsOn": [ + "[resourceId('Microsoft.Storage/storageAccounts/blobServices', parameters('name'), 'default')]" + ] + } + ], + "outputs": { + "name": { + "type": "string", + "metadata": { + "description": "Storage account name" + }, + "value": "[parameters('name')]" + }, + "id": { + "type": "string", + "metadata": { + "description": "Storage account resource ID (for RBAC + private endpoint)" + }, + "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('name'))]" + }, + "blobEndpoint": { + "type": "string", + "metadata": { + "description": "Primary blob endpoint, e.g. https://stagentbase.blob.core.windows.net/" + }, + "value": "[reference(resourceId('Microsoft.Storage/storageAccounts', parameters('name')), '2023-05-01').primaryEndpoints.blob]" + }, + "uploadsContainerName": { + "type": "string", + "metadata": { + "description": "Uploads container name" + }, + "value": "[parameters('uploadsContainerName')]" + } + } + } + } + }, + "postgres": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "postgres", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('postgresName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "administratorPassword": { + "value": "[parameters('postgresAdminPassword')]" + }, + "allowPublicAccess": { + "value": "[not(parameters('deployPrivateNetworking'))]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "16651341635583131599" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Globally-unique server name (lowercase, 3-63 chars)" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "administratorLogin": { + "type": "string", + "defaultValue": "agentbase", + "metadata": { + "description": "PostgreSQL administrator login" + } + }, + "administratorPassword": { + "type": "securestring", + "metadata": { + "description": "PostgreSQL administrator password" + } + }, + "databaseName": { + "type": "string", + "defaultValue": "agentbase", + "metadata": { + "description": "Application database name" + } + }, + "allowPublicAccess": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Allow public network access with Azure-services firewall rule (staging). False = private only (prod)." + } + } + }, + "resources": [ + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "apiVersion": "2023-12-01-preview", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Standard_B1ms", + "tier": "Burstable" + }, + "properties": { + "version": "16", + "administratorLogin": "[parameters('administratorLogin')]", + "administratorLoginPassword": "[parameters('administratorPassword')]", + "storage": { + "storageSizeGB": 32 + }, + "backup": { + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled" + }, + "highAvailability": { + "mode": "Disabled" + }, + "network": { + "publicNetworkAccess": "[if(parameters('allowPublicAccess'), 'Enabled', 'Disabled')]" + } + } + }, + { + "condition": "[parameters('allowPublicAccess')]", + "type": "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules", + "apiVersion": "2023-12-01-preview", + "name": "[format('{0}/{1}', parameters('name'), 'AllowAllAzureServices')]", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('name'))]" + ] + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers/databases", + "apiVersion": "2023-12-01-preview", + "name": "[format('{0}/{1}', parameters('name'), parameters('databaseName'))]", + "properties": { + "charset": "UTF8", + "collation": "en_US.utf8" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('name'))]" + ] + } + ], + "outputs": { + "fqdn": { + "type": "string", + "metadata": { + "description": "Fully-qualified domain name, e.g. psql-agentbase.postgres.database.azure.com" + }, + "value": "[reference(resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('name')), '2023-12-01-preview').fullyQualifiedDomainName]" + }, + "id": { + "type": "string", + "metadata": { + "description": "Server resource ID (for private endpoint)" + }, + "value": "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('name'))]" + }, + "administratorLogin": { + "type": "string", + "metadata": { + "description": "Administrator login (non-secret)" + }, + "value": "[parameters('administratorLogin')]" + }, + "databaseName": { + "type": "string", + "metadata": { + "description": "Application database name" + }, + "value": "[parameters('databaseName')]" + } + } + } + } + }, + "cosmos": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "cosmos", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('cosmosName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "disablePublicAccess": { + "value": "[parameters('deployPrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "665208588680794035" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Globally-unique Cosmos account name (lowercase, 3-44 chars)" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "databaseName": { + "type": "string", + "defaultValue": "agentbase", + "metadata": { + "description": "Mongo database name" + } + }, + "disablePublicAccess": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Disable public network access (true in prod — reach via private endpoint)" + } + } + }, + "resources": [ + { + "type": "Microsoft.DocumentDB/databaseAccounts", + "apiVersion": "2024-05-15", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "MongoDB", + "properties": { + "databaseAccountOfferType": "Standard", + "apiProperties": { + "serverVersion": "7.0" + }, + "capabilities": [ + { + "name": "EnableServerless" + } + ], + "consistencyPolicy": { + "defaultConsistencyLevel": "Session" + }, + "locations": [ + { + "locationName": "[parameters('location')]", + "failoverPriority": 0, + "isZoneRedundant": false + } + ], + "publicNetworkAccess": "[if(parameters('disablePublicAccess'), 'Disabled', 'Enabled')]", + "disableLocalAuth": false + } + }, + { + "type": "Microsoft.DocumentDB/databaseAccounts/mongodbDatabases", + "apiVersion": "2024-05-15", + "name": "[format('{0}/{1}', parameters('name'), parameters('databaseName'))]", + "properties": { + "resource": { + "id": "[parameters('databaseName')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name'))]" + ] + } + ], + "outputs": { + "accountName": { + "type": "string", + "metadata": { + "description": "Cosmos account name (pipeline uses this to fetch the connection string)" + }, + "value": "[parameters('name')]" + }, + "id": { + "type": "string", + "metadata": { + "description": "Cosmos account resource ID (for private endpoint)" + }, + "value": "[resourceId('Microsoft.DocumentDB/databaseAccounts', parameters('name'))]" + }, + "databaseName": { + "type": "string", + "metadata": { + "description": "Mongo database name" + }, + "value": "[parameters('databaseName')]" + } + } + } + } + }, + "redis": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "redis", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('redisName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "disablePublicAccess": { + "value": "[parameters('deployPrivateNetworking')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "14527038197571355044" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Globally-unique Redis name (1-63 chars)" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "disablePublicAccess": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Disable public network access (true in prod — reach via private endpoint)" + } + } + }, + "resources": [ + { + "type": "Microsoft.Cache/redis", + "apiVersion": "2024-03-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "name": "Basic", + "family": "C", + "capacity": 0 + }, + "enableNonSslPort": false, + "minimumTlsVersion": "1.2", + "publicNetworkAccess": "[if(parameters('disablePublicAccess'), 'Disabled', 'Enabled')]", + "redisConfiguration": {} + } + } + ], + "outputs": { + "hostName": { + "type": "string", + "metadata": { + "description": "Redis hostname, e.g. redis-agentbase.redis.cache.windows.net" + }, + "value": "[reference(resourceId('Microsoft.Cache/redis', parameters('name')), '2024-03-01').hostName]" + }, + "sslPort": { + "type": "int", + "metadata": { + "description": "SSL port (always 6380)" + }, + "value": "[reference(resourceId('Microsoft.Cache/redis', parameters('name')), '2024-03-01').sslPort]" + }, + "name": { + "type": "string", + "metadata": { + "description": "Redis name (pipeline uses this to fetch the primary access key)" + }, + "value": "[parameters('name')]" + }, + "id": { + "type": "string", + "metadata": { + "description": "Redis resource ID (for private endpoint)" + }, + "value": "[resourceId('Microsoft.Cache/redis', parameters('name'))]" + } + } + } + } + }, + "networking": { + "condition": "[parameters('deployPrivateNetworking')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "networking", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('nameBase')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "privateEndpoints": { + "value": [ + { + "name": "pe-postgres", + "serviceId": "[reference('postgres').outputs.id.value]", + "groupId": "postgresqlServer", + "dnsZoneName": "privatelink.postgres.database.azure.com" + }, + { + "name": "pe-cosmos", + "serviceId": "[reference('cosmos').outputs.id.value]", + "groupId": "MongoDB", + "dnsZoneName": "privatelink.mongo.cosmos.azure.com" + }, + { + "name": "pe-redis", + "serviceId": "[reference('redis').outputs.id.value]", + "groupId": "redisCache", + "dnsZoneName": "privatelink.redis.cache.windows.net" + }, + { + "name": "pe-blob", + "serviceId": "[reference('storage').outputs.id.value]", + "groupId": "blob", + "dnsZoneName": "privatelink.blob.core.windows.net" + }, + { + "name": "pe-keyvault", + "serviceId": "[reference('keyVault').outputs.id.value]", + "groupId": "vault", + "dnsZoneName": "privatelink.vaultcore.azure.net" + } + ] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "15350772663376185067" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Base name for networking resources" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "10.20.0.0/16", + "metadata": { + "description": "VNet address space" + } + }, + "appSubnetPrefix": { + "type": "string", + "defaultValue": "10.20.1.0/24", + "metadata": { + "description": "App Service regional integration subnet prefix" + } + }, + "privateEndpointSubnetPrefix": { + "type": "string", + "defaultValue": "10.20.2.0/24", + "metadata": { + "description": "Private endpoint subnet prefix" + } + }, + "privateEndpoints": { + "type": "array", + "metadata": { + "description": "Private endpoint specs: { name, serviceId, groupId, dnsZoneName }" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2023-11-01", + "name": "[format('vnet-{0}', parameters('name'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "addressSpace": { + "addressPrefixes": [ + "[parameters('vnetAddressPrefix')]" + ] + }, + "subnets": [ + { + "name": "snet-app", + "properties": { + "addressPrefix": "[parameters('appSubnetPrefix')]", + "delegations": [ + { + "name": "appservice-delegation", + "properties": { + "serviceName": "Microsoft.Web/serverFarms" + } + } + ] + } + }, + { + "name": "snet-pe", + "properties": { + "addressPrefix": "[parameters('privateEndpointSubnetPrefix')]", + "privateEndpointNetworkPolicies": "Disabled" + } + } + ] + } + }, + { + "copy": { + "name": "dnsZones", + "count": "[length(parameters('privateEndpoints'))]" + }, + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "[parameters('privateEndpoints')[copyIndex()].dnsZoneName]", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "copy": { + "name": "dnsZoneLinks", + "count": "[length(parameters('privateEndpoints'))]" + }, + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', parameters('privateEndpoints')[copyIndex()].dnsZoneName, format('link-{0}', parameters('name')))]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', parameters('name')))]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('privateEndpoints')[copyIndex()].dnsZoneName)]", + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', parameters('name')))]" + ] + }, + { + "copy": { + "name": "endpoints", + "count": "[length(parameters('privateEndpoints'))]" + }, + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2023-11-01", + "name": "[format('pe-{0}', parameters('privateEndpoints')[copyIndex()].name)]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('vnet-{0}', parameters('name')), 'snet-pe')]" + }, + "privateLinkServiceConnections": [ + { + "name": "[parameters('privateEndpoints')[copyIndex()].name]", + "properties": { + "privateLinkServiceId": "[parameters('privateEndpoints')[copyIndex()].serviceId]", + "groupIds": [ + "[parameters('privateEndpoints')[copyIndex()].groupId]" + ] + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', parameters('name')))]" + ] + }, + { + "copy": { + "name": "endpointDnsGroups", + "count": "[length(parameters('privateEndpoints'))]" + }, + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2023-11-01", + "name": "[format('{0}/{1}', format('pe-{0}', parameters('privateEndpoints')[copyIndex()].name), 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "[replace(parameters('privateEndpoints')[copyIndex()].dnsZoneName, '.', '-')]", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', parameters('privateEndpoints')[copyIndex()].dnsZoneName)]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', parameters('privateEndpoints')[copyIndex()].dnsZoneName)]", + "[resourceId('Microsoft.Network/privateEndpoints', format('pe-{0}', parameters('privateEndpoints')[copyIndex()].name))]" + ] + } + ], + "outputs": { + "appSubnetId": { + "type": "string", + "metadata": { + "description": "App Service regional VNet integration subnet ID" + }, + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('vnet-{0}', parameters('name')), 'snet-app')]" + }, + "peSubnetId": { + "type": "string", + "metadata": { + "description": "Private endpoint subnet ID" + }, + "value": "[resourceId('Microsoft.Network/virtualNetworks/subnets', format('vnet-{0}', parameters('name')), 'snet-pe')]" + }, + "vnetId": { + "type": "string", + "metadata": { + "description": "VNet resource ID (for private DNS zone VNet links)" + }, + "value": "[resourceId('Microsoft.Network/virtualNetworks', format('vnet-{0}', parameters('name')))]" + } + } + } + }, + "dependsOn": [ + "cosmos", + "keyVault", + "postgres", + "redis", + "storage" + ] + }, + "plan": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "plan", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[format('plan-{0}', variables('nameBase'))]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "skuName": { + "value": "[parameters('appServicePlanSku')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "3280843721837138958" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "App Service Plan name" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "skuName": { + "type": "string", + "defaultValue": "B2", + "metadata": { + "description": "SKU name, e.g. B2 (staging) or P1v2 (prod)" + } + } + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2023-12-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('skuName')]" + }, + "kind": "linux", + "properties": { + "reserved": true + } + } + ], + "outputs": { + "id": { + "type": "string", + "metadata": { + "description": "App Service Plan resource ID" + }, + "value": "[resourceId('Microsoft.Web/serverfarms', parameters('name'))]" + } + } + } + } + }, + "coreApp": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "coreApp", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('coreAppName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "appServicePlanId": { + "value": "[reference('plan').outputs.id.value]" + }, + "containerImage": { + "value": "[format('{0}/{1}-core:{2}', reference('acr').outputs.loginServer.value, parameters('project'), parameters('containerImageTag'))]" + }, + "acrLoginServer": { + "value": "[reference('acr').outputs.loginServer.value]" + }, + "websitesPort": { + "value": 3001 + }, + "appInsightsConnectionString": { + "value": "[reference('monitoring').outputs.connectionString.value]" + }, + "healthCheckPath": { + "value": "/api/health" + }, + "vnetSubnetId": "[if(parameters('deployPrivateNetworking'), createObject('value', reference('networking').outputs.appSubnetId.value), createObject('value', ''))]", + "appSettings": { + "value": [ + { + "name": "NODE_ENV", + "value": "production" + }, + { + "name": "APP_PORT", + "value": "3001" + }, + { + "name": "APP_URL", + "value": "[format('https://{0}', variables('coreHost'))]" + }, + { + "name": "FRONTEND_URL", + "value": "[format('https://{0}', variables('frontendHost'))]" + }, + { + "name": "AI_SERVICE_URL", + "value": "[format('https://{0}', variables('aiHost'))]" + }, + { + "name": "ENABLE_SWAGGER", + "value": "false" + }, + { + "name": "RUN_MIGRATIONS", + "value": "true" + }, + { + "name": "MARKETPLACE_URL", + "value": "[parameters('marketplaceUrl')]" + }, + { + "name": "POSTGRES_HOST", + "value": "[reference('postgres').outputs.fqdn.value]" + }, + { + "name": "POSTGRES_PORT", + "value": "5432" + }, + { + "name": "POSTGRES_USER", + "value": "[reference('postgres').outputs.administratorLogin.value]" + }, + { + "name": "POSTGRES_DB", + "value": "[reference('postgres').outputs.databaseName.value]" + }, + { + "name": "POSTGRES_SSL", + "value": "true" + }, + { + "name": "POSTGRES_PASSWORD", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'postgres-password')]" + }, + { + "name": "MONGO_URI", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'mongo-uri')]" + }, + { + "name": "REDIS_HOST", + "value": "[reference('redis').outputs.hostName.value]" + }, + { + "name": "REDIS_PORT", + "value": "[string(reference('redis').outputs.sslPort.value)]" + }, + { + "name": "REDIS_TLS", + "value": "true" + }, + { + "name": "REDIS_PASSWORD", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'redis-password')]" + }, + { + "name": "STORAGE_PROVIDER", + "value": "azure-blob" + }, + { + "name": "AZURE_STORAGE_ACCOUNT", + "value": "[reference('storage').outputs.name.value]" + }, + { + "name": "AZURE_STORAGE_BLOB_ENDPOINT", + "value": "[reference('storage').outputs.blobEndpoint.value]" + }, + { + "name": "AZURE_STORAGE_CONTAINER", + "value": "[reference('storage').outputs.uploadsContainerName.value]" + }, + { + "name": "JWT_SECRET", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'jwt-secret')]" + }, + { + "name": "JWT_REFRESH_SECRET", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'jwt-refresh-secret')]" + }, + { + "name": "ENCRYPTION_KEY", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'encryption-key')]" + }, + { + "name": "PLUGIN_SETTINGS_ENCRYPTION_KEY", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'plugin-settings-encryption-key')]" + }, + { + "name": "JWT_EXPIRATION", + "value": "24h" + }, + { + "name": "JWT_REFRESH_EXPIRATION", + "value": "7d" + }, + { + "name": "STRIPE_SECRET_KEY", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'stripe-secret-key')]" + }, + { + "name": "STRIPE_WEBHOOK_SECRET", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'stripe-webhook-secret')]" + }, + { + "name": "INTERNAL_SERVICE_TOKEN", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'internal-service-token')]" + } + ] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "11428718879091825981" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Web App name" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "appServicePlanId": { + "type": "string", + "metadata": { + "description": "App Service Plan resource ID" + } + }, + "containerImage": { + "type": "string", + "metadata": { + "description": "Container image reference, e.g. acragentbase.azurecr.io/agentbase-core:1234" + } + }, + "acrLoginServer": { + "type": "string", + "metadata": { + "description": "ACR login server (for managed-identity pull)" + } + }, + "websitesPort": { + "type": "int", + "metadata": { + "description": "Port the container listens on (WEBSITES_PORT)" + } + }, + "appInsightsConnectionString": { + "type": "string", + "metadata": { + "description": "Application Insights connection string" + } + }, + "healthCheckPath": { + "type": "string", + "metadata": { + "description": "Health check path, e.g. /api/health" + } + }, + "appSettings": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "App-specific settings (name/value pairs), including Key Vault references" + } + }, + "vnetSubnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional regional VNet integration subnet ID (prod). Empty = no integration." + } + }, + "alwaysOn": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Keep the app warm (Always On). Not supported on Free/Shared tiers." + } + }, + "ipSecurityRestrictions": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "IP security restrictions — pass an allow-list to lock inbound traffic. Empty = Azure default (allow all)." + } + } + }, + "variables": { + "baseAppSettings": [ + { + "name": "WEBSITES_PORT", + "value": "[string(parameters('websitesPort'))]" + }, + { + "name": "DOCKER_REGISTRY_SERVER_URL", + "value": "[format('https://{0}', parameters('acrLoginServer'))]" + }, + { + "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE", + "value": "false" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "ApplicationInsightsAgent_EXTENSION_VERSION", + "value": "~3" + } + ] + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "virtualNetworkSubnetId": "[if(empty(parameters('vnetSubnetId')), null(), parameters('vnetSubnetId'))]", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImage'))]", + "acrUseManagedIdentityCreds": true, + "alwaysOn": "[parameters('alwaysOn')]", + "ftpsState": "Disabled", + "minTlsVersion": "1.2", + "http20Enabled": true, + "healthCheckPath": "[parameters('healthCheckPath')]", + "vnetRouteAllEnabled": "[if(empty(parameters('vnetSubnetId')), false(), true())]", + "ipSecurityRestrictions": "[if(empty(parameters('ipSecurityRestrictions')), null(), parameters('ipSecurityRestrictions'))]", + "appSettings": "[concat(variables('baseAppSettings'), parameters('appSettings'))]" + } + } + } + ], + "outputs": { + "id": { + "type": "string", + "metadata": { + "description": "Resource ID (for private endpoints and cross-module references)" + }, + "value": "[resourceId('Microsoft.Web/sites', parameters('name'))]" + }, + "principalId": { + "type": "string", + "metadata": { + "description": "System-assigned managed identity principal ID (for RBAC)" + }, + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('name')), '2023-12-01', 'full').identity.principalId]" + }, + "defaultHostName": { + "type": "string", + "metadata": { + "description": "Default hostname, e.g. app-agentbase-core.azurewebsites.net" + }, + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('name')), '2023-12-01').defaultHostName]" + }, + "name": { + "type": "string", + "metadata": { + "description": "Web App name" + }, + "value": "[parameters('name')]" + } + } + } + }, + "dependsOn": [ + "acr", + "keyVault", + "monitoring", + "networking", + "plan", + "postgres", + "redis", + "storage" + ] + }, + "frontendApp": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "frontendApp", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('frontendAppName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "appServicePlanId": { + "value": "[reference('plan').outputs.id.value]" + }, + "containerImage": { + "value": "[format('{0}/{1}-frontend:{2}', reference('acr').outputs.loginServer.value, parameters('project'), parameters('containerImageTag'))]" + }, + "acrLoginServer": { + "value": "[reference('acr').outputs.loginServer.value]" + }, + "websitesPort": { + "value": 3000 + }, + "appInsightsConnectionString": { + "value": "[reference('monitoring').outputs.connectionString.value]" + }, + "healthCheckPath": { + "value": "/" + }, + "vnetSubnetId": "[if(parameters('deployPrivateNetworking'), createObject('value', reference('networking').outputs.appSubnetId.value), createObject('value', ''))]", + "appSettings": { + "value": [] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "11428718879091825981" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Web App name" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "appServicePlanId": { + "type": "string", + "metadata": { + "description": "App Service Plan resource ID" + } + }, + "containerImage": { + "type": "string", + "metadata": { + "description": "Container image reference, e.g. acragentbase.azurecr.io/agentbase-core:1234" + } + }, + "acrLoginServer": { + "type": "string", + "metadata": { + "description": "ACR login server (for managed-identity pull)" + } + }, + "websitesPort": { + "type": "int", + "metadata": { + "description": "Port the container listens on (WEBSITES_PORT)" + } + }, + "appInsightsConnectionString": { + "type": "string", + "metadata": { + "description": "Application Insights connection string" + } + }, + "healthCheckPath": { + "type": "string", + "metadata": { + "description": "Health check path, e.g. /api/health" + } + }, + "appSettings": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "App-specific settings (name/value pairs), including Key Vault references" + } + }, + "vnetSubnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional regional VNet integration subnet ID (prod). Empty = no integration." + } + }, + "alwaysOn": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Keep the app warm (Always On). Not supported on Free/Shared tiers." + } + }, + "ipSecurityRestrictions": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "IP security restrictions — pass an allow-list to lock inbound traffic. Empty = Azure default (allow all)." + } + } + }, + "variables": { + "baseAppSettings": [ + { + "name": "WEBSITES_PORT", + "value": "[string(parameters('websitesPort'))]" + }, + { + "name": "DOCKER_REGISTRY_SERVER_URL", + "value": "[format('https://{0}', parameters('acrLoginServer'))]" + }, + { + "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE", + "value": "false" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "ApplicationInsightsAgent_EXTENSION_VERSION", + "value": "~3" + } + ] + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "virtualNetworkSubnetId": "[if(empty(parameters('vnetSubnetId')), null(), parameters('vnetSubnetId'))]", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImage'))]", + "acrUseManagedIdentityCreds": true, + "alwaysOn": "[parameters('alwaysOn')]", + "ftpsState": "Disabled", + "minTlsVersion": "1.2", + "http20Enabled": true, + "healthCheckPath": "[parameters('healthCheckPath')]", + "vnetRouteAllEnabled": "[if(empty(parameters('vnetSubnetId')), false(), true())]", + "ipSecurityRestrictions": "[if(empty(parameters('ipSecurityRestrictions')), null(), parameters('ipSecurityRestrictions'))]", + "appSettings": "[concat(variables('baseAppSettings'), parameters('appSettings'))]" + } + } + } + ], + "outputs": { + "id": { + "type": "string", + "metadata": { + "description": "Resource ID (for private endpoints and cross-module references)" + }, + "value": "[resourceId('Microsoft.Web/sites', parameters('name'))]" + }, + "principalId": { + "type": "string", + "metadata": { + "description": "System-assigned managed identity principal ID (for RBAC)" + }, + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('name')), '2023-12-01', 'full').identity.principalId]" + }, + "defaultHostName": { + "type": "string", + "metadata": { + "description": "Default hostname, e.g. app-agentbase-core.azurewebsites.net" + }, + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('name')), '2023-12-01').defaultHostName]" + }, + "name": { + "type": "string", + "metadata": { + "description": "Web App name" + }, + "value": "[parameters('name')]" + } + } + } + }, + "dependsOn": [ + "acr", + "monitoring", + "networking", + "plan" + ] + }, + "aiApp": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aiApp", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "name": { + "value": "[variables('aiAppName')]" + }, + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "appServicePlanId": { + "value": "[reference('plan').outputs.id.value]" + }, + "containerImage": { + "value": "[format('{0}/{1}-ai-service:{2}', reference('acr').outputs.loginServer.value, parameters('project'), parameters('containerImageTag'))]" + }, + "acrLoginServer": { + "value": "[reference('acr').outputs.loginServer.value]" + }, + "websitesPort": { + "value": 8000 + }, + "appInsightsConnectionString": { + "value": "[reference('monitoring').outputs.connectionString.value]" + }, + "healthCheckPath": { + "value": "/api/ai/health" + }, + "vnetSubnetId": "[if(parameters('deployPrivateNetworking'), createObject('value', reference('networking').outputs.appSubnetId.value), createObject('value', ''))]", + "ipSecurityRestrictions": "[if(parameters('deployPrivateNetworking'), createObject('value', createArray(createObject('name', 'allow-core-vnet', 'priority', 100, 'action', 'Allow', 'vnetSubnetResourceId', reference('networking').outputs.appSubnetId.value, 'description', 'Allow inbound from App Service VNet integration subnet (core app only)'))), createObject('value', createArray()))]", + "appSettings": { + "value": [ + { + "name": "MONGO_URI", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'mongo-uri')]" + }, + { + "name": "OPENAI_API_KEY", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'openai-api-key')]" + }, + { + "name": "ANTHROPIC_API_KEY", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'anthropic-api-key')]" + }, + { + "name": "GEMINI_API_KEY", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'gemini-api-key')]" + }, + { + "name": "HUGGINGFACE_API_KEY", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'huggingface-api-key')]" + }, + { + "name": "CORE_API_URL", + "value": "[format('https://{0}', variables('coreHost'))]" + }, + { + "name": "INTERNAL_SERVICE_TOKEN", + "value": "[__bicep.kvRef(reference('keyVault').outputs.uri.value, 'internal-service-token')]" + } + ] + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "11428718879091825981" + } + }, + "parameters": { + "name": { + "type": "string", + "metadata": { + "description": "Web App name" + } + }, + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "appServicePlanId": { + "type": "string", + "metadata": { + "description": "App Service Plan resource ID" + } + }, + "containerImage": { + "type": "string", + "metadata": { + "description": "Container image reference, e.g. acragentbase.azurecr.io/agentbase-core:1234" + } + }, + "acrLoginServer": { + "type": "string", + "metadata": { + "description": "ACR login server (for managed-identity pull)" + } + }, + "websitesPort": { + "type": "int", + "metadata": { + "description": "Port the container listens on (WEBSITES_PORT)" + } + }, + "appInsightsConnectionString": { + "type": "string", + "metadata": { + "description": "Application Insights connection string" + } + }, + "healthCheckPath": { + "type": "string", + "metadata": { + "description": "Health check path, e.g. /api/health" + } + }, + "appSettings": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "App-specific settings (name/value pairs), including Key Vault references" + } + }, + "vnetSubnetId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional regional VNet integration subnet ID (prod). Empty = no integration." + } + }, + "alwaysOn": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Keep the app warm (Always On). Not supported on Free/Shared tiers." + } + }, + "ipSecurityRestrictions": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "IP security restrictions — pass an allow-list to lock inbound traffic. Empty = Azure default (allow all)." + } + } + }, + "variables": { + "baseAppSettings": [ + { + "name": "WEBSITES_PORT", + "value": "[string(parameters('websitesPort'))]" + }, + { + "name": "DOCKER_REGISTRY_SERVER_URL", + "value": "[format('https://{0}', parameters('acrLoginServer'))]" + }, + { + "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE", + "value": "false" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "ApplicationInsightsAgent_EXTENSION_VERSION", + "value": "~3" + } + ] + }, + "resources": [ + { + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", + "name": "[parameters('name')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "virtualNetworkSubnetId": "[if(empty(parameters('vnetSubnetId')), null(), parameters('vnetSubnetId'))]", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|{0}', parameters('containerImage'))]", + "acrUseManagedIdentityCreds": true, + "alwaysOn": "[parameters('alwaysOn')]", + "ftpsState": "Disabled", + "minTlsVersion": "1.2", + "http20Enabled": true, + "healthCheckPath": "[parameters('healthCheckPath')]", + "vnetRouteAllEnabled": "[if(empty(parameters('vnetSubnetId')), false(), true())]", + "ipSecurityRestrictions": "[if(empty(parameters('ipSecurityRestrictions')), null(), parameters('ipSecurityRestrictions'))]", + "appSettings": "[concat(variables('baseAppSettings'), parameters('appSettings'))]" + } + } + } + ], + "outputs": { + "id": { + "type": "string", + "metadata": { + "description": "Resource ID (for private endpoints and cross-module references)" + }, + "value": "[resourceId('Microsoft.Web/sites', parameters('name'))]" + }, + "principalId": { + "type": "string", + "metadata": { + "description": "System-assigned managed identity principal ID (for RBAC)" + }, + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('name')), '2023-12-01', 'full').identity.principalId]" + }, + "defaultHostName": { + "type": "string", + "metadata": { + "description": "Default hostname, e.g. app-agentbase-core.azurewebsites.net" + }, + "value": "[reference(resourceId('Microsoft.Web/sites', parameters('name')), '2023-12-01').defaultHostName]" + }, + "name": { + "type": "string", + "metadata": { + "description": "Web App name" + }, + "value": "[parameters('name')]" + } + } + } + }, + "dependsOn": [ + "acr", + "keyVault", + "monitoring", + "networking", + "plan" + ] + }, + "coreRbac": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "coreRbac", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference('coreApp').outputs.principalId.value]" + }, + "acrName": { + "value": "[reference('acr').outputs.name.value]" + }, + "keyVaultName": { + "value": "[reference('keyVault').outputs.name.value]" + }, + "storageAccountName": { + "value": "[reference('storage').outputs.name.value]" + }, + "assignKeyVaultRole": { + "value": true + }, + "assignStorageRole": { + "value": true + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "2886755929278652994" + } + }, + "parameters": { + "principalId": { + "type": "string", + "metadata": { + "description": "Managed identity principal ID to grant roles to" + } + }, + "acrName": { + "type": "string", + "metadata": { + "description": "ACR name (scope for AcrPull)" + } + }, + "keyVaultName": { + "type": "string", + "metadata": { + "description": "Key Vault name (scope for Key Vault Secrets User)" + } + }, + "storageAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Storage account name (scope for Storage Blob Data Contributor). Empty = skip." + } + }, + "assignKeyVaultRole": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Whether to assign Key Vault Secrets User (skip for apps with no secrets, e.g. frontend)" + } + }, + "assignStorageRole": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Whether to assign the Storage Blob Data Contributor role" + } + } + }, + "variables": { + "acrPullRoleId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", + "keyVaultSecretsUserRoleId": "4633458b-17de-408a-b874-0445c86b69e6", + "storageBlobDataContributorRoleId": "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), parameters('principalId'), variables('acrPullRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('acrPullRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('assignKeyVaultRole')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), parameters('principalId'), variables('keyVaultSecretsUserRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('keyVaultSecretsUserRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('assignStorageRole')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", + "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('principalId'), variables('storageBlobDataContributorRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('storageBlobDataContributorRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + } + ] + } + }, + "dependsOn": [ + "acr", + "coreApp", + "keyVault", + "storage" + ] + }, + "frontendRbac": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "frontendRbac", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference('frontendApp').outputs.principalId.value]" + }, + "acrName": { + "value": "[reference('acr').outputs.name.value]" + }, + "keyVaultName": { + "value": "[reference('keyVault').outputs.name.value]" + }, + "assignKeyVaultRole": { + "value": false + }, + "assignStorageRole": { + "value": false + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "2886755929278652994" + } + }, + "parameters": { + "principalId": { + "type": "string", + "metadata": { + "description": "Managed identity principal ID to grant roles to" + } + }, + "acrName": { + "type": "string", + "metadata": { + "description": "ACR name (scope for AcrPull)" + } + }, + "keyVaultName": { + "type": "string", + "metadata": { + "description": "Key Vault name (scope for Key Vault Secrets User)" + } + }, + "storageAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Storage account name (scope for Storage Blob Data Contributor). Empty = skip." + } + }, + "assignKeyVaultRole": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Whether to assign Key Vault Secrets User (skip for apps with no secrets, e.g. frontend)" + } + }, + "assignStorageRole": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Whether to assign the Storage Blob Data Contributor role" + } + } + }, + "variables": { + "acrPullRoleId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", + "keyVaultSecretsUserRoleId": "4633458b-17de-408a-b874-0445c86b69e6", + "storageBlobDataContributorRoleId": "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), parameters('principalId'), variables('acrPullRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('acrPullRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('assignKeyVaultRole')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), parameters('principalId'), variables('keyVaultSecretsUserRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('keyVaultSecretsUserRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('assignStorageRole')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", + "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('principalId'), variables('storageBlobDataContributorRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('storageBlobDataContributorRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + } + ] + } + }, + "dependsOn": [ + "acr", + "frontendApp", + "keyVault" + ] + }, + "aiRbac": { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aiRbac", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "principalId": { + "value": "[reference('aiApp').outputs.principalId.value]" + }, + "acrName": { + "value": "[reference('acr').outputs.name.value]" + }, + "keyVaultName": { + "value": "[reference('keyVault').outputs.name.value]" + }, + "assignKeyVaultRole": { + "value": true + }, + "assignStorageRole": { + "value": false + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "2886755929278652994" + } + }, + "parameters": { + "principalId": { + "type": "string", + "metadata": { + "description": "Managed identity principal ID to grant roles to" + } + }, + "acrName": { + "type": "string", + "metadata": { + "description": "ACR name (scope for AcrPull)" + } + }, + "keyVaultName": { + "type": "string", + "metadata": { + "description": "Key Vault name (scope for Key Vault Secrets User)" + } + }, + "storageAccountName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Storage account name (scope for Storage Blob Data Contributor). Empty = skip." + } + }, + "assignKeyVaultRole": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Whether to assign Key Vault Secrets User (skip for apps with no secrets, e.g. frontend)" + } + }, + "assignStorageRole": { + "type": "bool", + "defaultValue": false, + "metadata": { + "description": "Whether to assign the Storage Blob Data Contributor role" + } + } + }, + "variables": { + "acrPullRoleId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", + "keyVaultSecretsUserRoleId": "4633458b-17de-408a-b874-0445c86b69e6", + "storageBlobDataContributorRoleId": "ba92f5b4-2d11-453d-a403-e96b0029c9fe" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), parameters('principalId'), variables('acrPullRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('acrPullRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('assignKeyVaultRole')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), parameters('principalId'), variables('keyVaultSecretsUserRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('keyVaultSecretsUserRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + }, + { + "condition": "[parameters('assignStorageRole')]", + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]", + "name": "[guid(resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName')), parameters('principalId'), variables('storageBlobDataContributorRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('storageBlobDataContributorRoleId'))]", + "principalId": "[parameters('principalId')]", + "principalType": "ServicePrincipal" + } + } + ] + } + }, + "dependsOn": [ + "acr", + "aiApp", + "keyVault" + ] + }, + "aiServicePe": { + "condition": "[parameters('deployPrivateNetworking')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aiServicePe", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "aiAppId": { + "value": "[reference('aiApp').outputs.id.value]" + }, + "peSubnetId": { + "value": "[reference('networking').outputs.peSubnetId.value]" + }, + "vnetId": { + "value": "[reference('networking').outputs.vnetId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "4779090262380594643" + } + }, + "parameters": { + "location": { + "type": "string", + "metadata": { + "description": "Azure region" + } + }, + "tags": { + "type": "object", + "metadata": { + "description": "Resource tags" + } + }, + "aiAppId": { + "type": "string", + "metadata": { + "description": "AI service App Service resource ID" + } + }, + "peSubnetId": { + "type": "string", + "metadata": { + "description": "Private endpoint subnet resource ID (snet-pe)" + } + }, + "vnetId": { + "type": "string", + "metadata": { + "description": "VNet resource ID for private DNS zone link" + } + } + }, + "resources": [ + { + "type": "Microsoft.Network/privateEndpoints", + "apiVersion": "2023-11-01", + "name": "pe-ai-service", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "subnet": { + "id": "[parameters('peSubnetId')]" + }, + "privateLinkServiceConnections": [ + { + "name": "pe-ai-service", + "properties": { + "privateLinkServiceId": "[parameters('aiAppId')]", + "groupIds": [ + "sites" + ] + } + } + ] + } + }, + { + "type": "Microsoft.Network/privateDnsZones", + "apiVersion": "2020-06-01", + "name": "privatelink.azurewebsites.net", + "location": "global", + "tags": "[parameters('tags')]" + }, + { + "type": "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "apiVersion": "2020-06-01", + "name": "[format('{0}/{1}', 'privatelink.azurewebsites.net', 'link-ai-service')]", + "location": "global", + "properties": { + "registrationEnabled": false, + "virtualNetwork": { + "id": "[parameters('vnetId')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.azurewebsites.net')]" + ] + }, + { + "type": "Microsoft.Network/privateEndpoints/privateDnsZoneGroups", + "apiVersion": "2023-11-01", + "name": "[format('{0}/{1}', 'pe-ai-service', 'default')]", + "properties": { + "privateDnsZoneConfigs": [ + { + "name": "privatelink-azurewebsites-net", + "properties": { + "privateDnsZoneId": "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.azurewebsites.net')]" + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/privateDnsZones', 'privatelink.azurewebsites.net')]", + "[resourceId('Microsoft.Network/privateEndpoints', 'pe-ai-service')]" + ] + } + ] + } + }, + "dependsOn": [ + "aiApp", + "networking" + ] + } + }, + "outputs": { + "acrName": { + "type": "string", + "value": "[reference('acr').outputs.name.value]" + }, + "acrLoginServer": { + "type": "string", + "value": "[reference('acr').outputs.loginServer.value]" + }, + "keyVaultName": { + "type": "string", + "value": "[reference('keyVault').outputs.name.value]" + }, + "cosmosAccountName": { + "type": "string", + "value": "[reference('cosmos').outputs.accountName.value]" + }, + "redisName": { + "type": "string", + "value": "[reference('redis').outputs.name.value]" + }, + "coreAppName": { + "type": "string", + "value": "[reference('coreApp').outputs.name.value]" + }, + "frontendAppName": { + "type": "string", + "value": "[reference('frontendApp').outputs.name.value]" + }, + "aiAppName": { + "type": "string", + "value": "[reference('aiApp').outputs.name.value]" + }, + "coreUrl": { + "type": "string", + "value": "[format('https://{0}', variables('coreHost'))]" + }, + "frontendUrl": { + "type": "string", + "value": "[format('https://{0}', variables('frontendHost'))]" + }, + "aiUrl": { + "type": "string", + "value": "[format('https://{0}', variables('aiHost'))]" + } + } +} \ No newline at end of file diff --git a/infra/modules/app-service-container.bicep b/infra/modules/app-service-container.bicep index 060b691..bf5ceb1 100644 --- a/infra/modules/app-service-container.bicep +++ b/infra/modules/app-service-container.bicep @@ -39,6 +39,9 @@ param vnetSubnetId string = '' @description('Keep the app warm (Always On). Not supported on Free/Shared tiers.') param alwaysOn bool = true +@description('IP security restrictions — pass an allow-list to lock inbound traffic. Empty = Azure default (allow all).') +param ipSecurityRestrictions array = [] + // Settings common to every container app — merged with the caller's app-specific settings. var baseAppSettings = [ { @@ -84,11 +87,15 @@ resource app 'Microsoft.Web/sites@2023-12-01' = { http20Enabled: true healthCheckPath: healthCheckPath vnetRouteAllEnabled: empty(vnetSubnetId) ? false : true + ipSecurityRestrictions: empty(ipSecurityRestrictions) ? null : ipSecurityRestrictions appSettings: concat(baseAppSettings, appSettings) } } } +@description('Resource ID (for private endpoints and cross-module references)') +output id string = app.id + @description('System-assigned managed identity principal ID (for RBAC)') output principalId string = app.identity.principalId diff --git a/infra/modules/networking.bicep b/infra/modules/networking.bicep index ad31876..32fa919 100644 --- a/infra/modules/networking.bicep +++ b/infra/modules/networking.bicep @@ -133,3 +133,9 @@ resource endpointDnsGroups 'Microsoft.Network/privateEndpoints/privateDnsZoneGro @description('App Service regional VNet integration subnet ID') output appSubnetId string = appSubnet.id + +@description('Private endpoint subnet ID') +output peSubnetId string = peSubnet.id + +@description('VNet resource ID (for private DNS zone VNet links)') +output vnetId string = vnet.id diff --git a/infra/modules/pe-ai-service.bicep b/infra/modules/pe-ai-service.bicep new file mode 100644 index 0000000..dacc54d --- /dev/null +++ b/infra/modules/pe-ai-service.bicep @@ -0,0 +1,74 @@ +// pe-ai-service.bicep — Private endpoint for the AI service App Service (prod only). +// Deployed as a separate module so it can depend on both the AI App (for its resource ID) +// and the networking module (for the PE subnet ID) without creating a circular dependency +// in main.bicep (networking already runs before aiApp due to subnet delegation). + +@description('Azure region') +param location string + +@description('Resource tags') +param tags object + +@description('AI service App Service resource ID') +param aiAppId string + +@description('Private endpoint subnet resource ID (snet-pe)') +param peSubnetId string + +@description('VNet resource ID for private DNS zone link') +param vnetId string + +resource pe 'Microsoft.Network/privateEndpoints@2023-11-01' = { + name: 'pe-ai-service' + location: location + tags: tags + properties: { + subnet: { + id: peSubnetId + } + privateLinkServiceConnections: [ + { + name: 'pe-ai-service' + properties: { + privateLinkServiceId: aiAppId + groupIds: ['sites'] + } + } + ] + } +} + +// Private-link DNS zone names are fixed FQDNs (public cloud), not env-derived. +#disable-next-line no-hardcoded-env-urls +resource dnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { + name: 'privatelink.azurewebsites.net' + location: 'global' + tags: tags +} + +resource dnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { + parent: dnsZone + name: 'link-ai-service' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { + id: vnetId + } + } +} + +resource dnsZoneGroup 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2023-11-01' = { + parent: pe + name: 'default' + properties: { + privateDnsZoneConfigs: [ + { + name: 'privatelink-azurewebsites-net' + properties: { + privateDnsZoneId: dnsZone.id + } + } + ] + } +} diff --git a/packages/ai-service/app/core/config.py b/packages/ai-service/app/core/config.py index d6d34d9..26fcb9a 100644 --- a/packages/ai-service/app/core/config.py +++ b/packages/ai-service/app/core/config.py @@ -9,6 +9,9 @@ class Settings(BaseSettings): AI_SERVICE_PORT: int = 8000 FRONTEND_URL: str = "http://localhost:3000" CORE_API_URL: str = "http://localhost:3001" + # Shared secret presented by core on every internal call. When empty (local dev), + # the token check is skipped so local development works without setup. + INTERNAL_SERVICE_TOKEN: Optional[str] = None # MongoDB MONGO_URI: str = "mongodb://agentbase:agentbase_dev@localhost:27017/agentbase?authSource=admin" diff --git a/packages/ai-service/app/main.py b/packages/ai-service/app/main.py index 1f6a5a6..24886c6 100644 --- a/packages/ai-service/app/main.py +++ b/packages/ai-service/app/main.py @@ -3,6 +3,7 @@ import time from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse from contextlib import asynccontextmanager from app.core.config import settings @@ -32,15 +33,41 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) -# CORS +# CORS — restricted to core API only. The browser never calls the AI service directly; +# all requests flow through the NestJS core, which proxies to this service. app.add_middleware( CORSMiddleware, - allow_origins=[settings.FRONTEND_URL, settings.CORE_API_URL], + allow_origins=[settings.CORE_API_URL], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +# Health paths that must remain open for App Service probes and internal checks. +_OPEN_PATHS = {"/api/health", "/api/ai/health", "/docs", "/openapi.json", "/redoc"} + + +@app.middleware("http") +async def internal_token_auth(request: Request, call_next): + """Enforce X-Internal-Token on all /api/ai/* routes when token is configured. + + Skipped when INTERNAL_SERVICE_TOKEN is not set (local dev). Health endpoints + are always open so App Service probes can reach them without credentials. + """ + path = request.url.path + if ( + settings.INTERNAL_SERVICE_TOKEN + and path.startswith("/api/ai/") + and path not in _OPEN_PATHS + ): + token = request.headers.get("x-internal-token") + if not token or token != settings.INTERNAL_SERVICE_TOKEN: + return JSONResponse( + status_code=401, + content={"detail": "Unauthorized"}, + ) + return await call_next(request) + @app.middleware("http") async def log_requests(request: Request, call_next): diff --git a/packages/ai-service/app/routers/streaming.py b/packages/ai-service/app/routers/streaming.py index 03b7d86..159abc8 100644 --- a/packages/ai-service/app/routers/streaming.py +++ b/packages/ai-service/app/routers/streaming.py @@ -1,10 +1,12 @@ """Streaming AI response endpoint using Server-Sent Events.""" +import asyncio +import json +from typing import Optional + from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel -from typing import Optional -import json from app.core.config import settings from app.services.ai_providers import ( @@ -63,15 +65,16 @@ async def stream_message(conversation_id: str, req: StreamMessageRequest): ) async def event_generator(): + from datetime import datetime + full_response = "" + stream = provider.chat_stream(chat_request) try: - async for chunk in provider.chat_stream(chat_request): + async for chunk in stream: full_response += chunk yield f"data: {json.dumps({'type': 'chunk', 'content': chunk})}\n\n" - # Store messages after stream completes - from datetime import datetime - + # Stream completed normally — persist the exchange to MongoDB. user_msg = { "role": "user", "content": req.content, @@ -97,8 +100,18 @@ async def event_generator(): ) yield f"data: {json.dumps({'type': 'done', 'content': full_response})}\n\n" + + except (asyncio.CancelledError, GeneratorExit): + # Client disconnected mid-stream. Re-raise so Starlette closes the + # response cleanly; the finally block ensures the upstream generator + # releases its connection regardless of the exit path. + raise except Exception as e: yield f"data: {json.dumps({'type': 'error', 'content': str(e)})}\n\n" + finally: + # aclose() is idempotent — safe to call even if the async for loop + # already closed the generator on normal exit or exception. + await stream.aclose() return StreamingResponse( event_generator(), diff --git a/packages/core/package.json b/packages/core/package.json index c101273..c8283df 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,11 +36,13 @@ "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^8.1.0", "@nestjs/typeorm": "^10.0.2", + "@types/ioredis": "^5.0.0", "axios": "^1.13.6", "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "helmet": "^8.1.0", + "ioredis": "^5.11.1", "mongoose": "^8.9.0", "nestjs-pino": "^4.6.0", "nodemailer": "^8.0.1", diff --git a/packages/core/src/app.module.ts b/packages/core/src/app.module.ts index 8dd8a21..880026d 100644 --- a/packages/core/src/app.module.ts +++ b/packages/core/src/app.module.ts @@ -35,6 +35,7 @@ import { SystemHealthModule } from "./modules/system-health/system-health.module import { StripeModule } from "./modules/stripe/stripe.module"; import { ProviderKeysModule } from "./modules/provider-keys/provider-keys.module"; import { ScheduleModule } from "@nestjs/schedule"; +import { RedisModule } from "./common/services/redis.module"; @Module({ imports: [ @@ -85,7 +86,10 @@ import { ScheduleModule } from "@nestjs/schedule"; autoLoadEntities: true, synchronize: false, // Use migrations instead migrations: ["dist/database/migrations/*.js"], - migrationsRun: config.get("RUN_MIGRATIONS") === "true", + // Migrations are NOT auto-run here. When RUN_MIGRATIONS=true they run once + // at bootstrap (main.ts) under a Postgres advisory lock, so multiple App + // Service instances starting concurrently can't race on schema DDL. + migrationsRun: false, logging: config.get("NODE_ENV") === "development", }), }), @@ -102,6 +106,9 @@ import { ScheduleModule } from "@nestjs/schedule"; }), }), + // Infrastructure + RedisModule, + // Feature modules StripeModule.forRoot(), ScheduleModule.forRoot(), diff --git a/packages/core/src/common/interceptors/rate-limit.interceptor.ts b/packages/core/src/common/interceptors/rate-limit.interceptor.ts index 08920bf..cfe3e74 100644 --- a/packages/core/src/common/interceptors/rate-limit.interceptor.ts +++ b/packages/core/src/common/interceptors/rate-limit.interceptor.ts @@ -3,48 +3,73 @@ import { HttpException, HttpStatus, Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; +import { RedisService } from '../services/redis.service'; /** - * Simple in-memory rate limiter for API key requests. - * In production, replace with Redis-backed limiter. + * Redis-backed rate limiter for public API key requests. + * Uses a fixed-window counter (INCR + EXPIRE) so the limit is enforced + * globally across all App Service instances. + * + * Falls back to fail-open (no limit enforced) when Redis is unavailable, + * so a Redis outage does not block legitimate API traffic. */ @Injectable() export class RateLimitInterceptor implements NestInterceptor { private readonly logger = new Logger(RateLimitInterceptor.name); private readonly windowMs = 60_000; // 1 minute - private readonly store = new Map(); - intercept(context: ExecutionContext, next: CallHandler): Observable { + // In-memory fallback used only when Redis is unavailable. + private readonly fallbackStore = new Map(); + + constructor(private readonly redisService: RedisService) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { const request = context.switchToHttp().getRequest(); const response = context.switchToHttp().getResponse(); const apiKey = request.apiKey; if (!apiKey) return next.handle(); - const key = apiKey.id; - const limit = apiKey.rateLimit || 100; - const now = Date.now(); + const keyId: string = apiKey.id; + const limit: number = apiKey.rateLimit || 100; + const windowSec = Math.ceil(this.windowMs / 1000); - let entry = this.store.get(key); - if (!entry || now > entry.resetAt) { - entry = { count: 0, resetAt: now + this.windowMs }; - this.store.set(key, entry); - } + let count: number; + let resetAt: number; + const redis = this.redisService.getClient(); - entry.count++; + if (redis) { + const redisKey = `rl:${keyId}`; + count = await this.redisService.increment(redisKey, windowSec); + const ttlSec = await this.redisService.ttl(redisKey); + resetAt = Math.floor(Date.now() / 1000) + ttlSec; + } else { + // Fallback: per-instance in-memory store (best-effort under Redis outage) + const now = Date.now(); + let entry = this.fallbackStore.get(keyId); + if (!entry || now > entry.resetAt) { + entry = { count: 0, resetAt: now + this.windowMs }; + this.fallbackStore.set(keyId, entry); + } + entry.count++; + count = entry.count; + resetAt = Math.ceil(entry.resetAt / 1000); + } - // Set rate limit headers response.setHeader('X-RateLimit-Limit', limit); - response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - entry.count)); - response.setHeader('X-RateLimit-Reset', Math.ceil(entry.resetAt / 1000)); + response.setHeader('X-RateLimit-Remaining', Math.max(0, limit - count)); + response.setHeader('X-RateLimit-Reset', resetAt); - if (entry.count > limit) { + if (count > limit) { this.logger.warn(`Rate limit exceeded for API key: ${apiKey.keyPrefix}`); - throw new HttpException({ - statusCode: HttpStatus.TOO_MANY_REQUESTS, - message: 'Rate limit exceeded', - retryAfter: Math.ceil((entry.resetAt - now) / 1000), - }, HttpStatus.TOO_MANY_REQUESTS); + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + message: 'Rate limit exceeded', + retryAfter: resetAt - Math.floor(Date.now() / 1000), + }, + HttpStatus.TOO_MANY_REQUESTS, + ); } return next.handle(); diff --git a/packages/core/src/common/services/redis.module.ts b/packages/core/src/common/services/redis.module.ts new file mode 100644 index 0000000..b9cfabf --- /dev/null +++ b/packages/core/src/common/services/redis.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/packages/core/src/common/services/redis.service.ts b/packages/core/src/common/services/redis.service.ts new file mode 100644 index 0000000..a80e2da --- /dev/null +++ b/packages/core/src/common/services/redis.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import Redis from 'ioredis'; + +@Injectable() +export class RedisService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(RedisService.name); + private client: Redis | null = null; + + constructor(private readonly config: ConfigService) {} + + onModuleInit() { + const host = this.config.get('REDIS_HOST', 'localhost'); + const port = this.config.get('REDIS_PORT', 6379); + const password = this.config.get('REDIS_PASSWORD'); + const tls = this.config.get('REDIS_TLS') === 'true'; + + this.client = new Redis({ + host, + port, + password: password || undefined, + tls: tls ? {} : undefined, + lazyConnect: true, + retryStrategy: (times) => Math.min(times * 200, 3000), + maxRetriesPerRequest: 1, + }); + + this.client.on('error', (err) => + this.logger.error('Redis connection error', err.message), + ); + this.client.on('connect', () => + this.logger.log(`Redis connected at ${host}:${port}`), + ); + + this.client.connect().catch((err) => + this.logger.warn('Redis initial connect failed — rate limiting will use in-memory fallback', err.message), + ); + } + + async onModuleDestroy() { + await this.client?.quit(); + } + + /** Returns the Redis client, or null if not connected. */ + getClient(): Redis | null { + return this.client?.status === 'ready' ? this.client : null; + } + + /** Increment a key and set its TTL on first write (fixed-window counter). */ + async increment(key: string, windowSeconds: number): Promise { + const redis = this.getClient(); + if (!redis) return 0; // fail open when Redis is unavailable + + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, windowSeconds); + } + return count; + } + + /** Return the remaining TTL (seconds) of a key, or the full window on miss. */ + async ttl(key: string): Promise { + const redis = this.getClient(); + if (!redis) return 0; + const t = await redis.ttl(key); + return t < 0 ? 0 : t; + } +} diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index f2ca2d5..b441d7a 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -2,9 +2,49 @@ import { NestFactory } from "@nestjs/core"; import { ValidationPipe, Logger, VersioningType } from "@nestjs/common"; import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger"; import { Logger as PinoLogger } from "nestjs-pino"; +import { DataSource } from "typeorm"; import { AppModule } from "./app.module"; import helmet from "helmet"; +// Arbitrary, application-specific advisory-lock key. Any instance that wants to +// run migrations must hold this lock first, so concurrent boots serialize. +const MIGRATION_LOCK_KEY = 4915623; + +/** + * Run pending TypeORM migrations under a Postgres advisory lock. + * + * App Service plans (B2/P1v2) support autoscale, so several core instances can + * boot at once. Without serialization each would see the same pending migrations + * and race on schema-altering DDL. The advisory lock gates entry to a single + * instance at a time; the `migrations` table makes the work idempotent (later + * holders find nothing pending and exit cleanly). + */ +async function runMigrationsWithLock(dataSource: DataSource, logger: Logger) { + const queryRunner = dataSource.createQueryRunner(); + await queryRunner.connect(); + try { + // Session-scoped lock — blocks other instances until released below. + await queryRunner.query("SELECT pg_advisory_lock($1)", [ + MIGRATION_LOCK_KEY, + ]); + const ran = await dataSource.runMigrations({ transaction: "all" }); + if (ran.length) { + logger.log( + `Applied ${ran.length} migration(s): ${ran + .map((m) => m.name) + .join(", ")}`, + ); + } else { + logger.log("No pending migrations"); + } + } finally { + await queryRunner.query("SELECT pg_advisory_unlock($1)", [ + MIGRATION_LOCK_KEY, + ]); + await queryRunner.release(); + } +} + async function bootstrap() { const app = await NestFactory.create(AppModule, { bufferLogs: true, @@ -108,6 +148,17 @@ async function bootstrap() { // Graceful shutdown app.enableShutdownHooks(); + // Run DB migrations once, serialized across instances via a Postgres advisory + // lock, before accepting traffic. See runMigrationsWithLock above. + if (process.env.RUN_MIGRATIONS === "true") { + try { + await runMigrationsWithLock(app.get(DataSource), logger); + } catch (err) { + logger.error("Migration run failed — aborting startup", err as Error); + throw err; + } + } + const port = process.env.APP_PORT || 3001; await app.listen(port); diff --git a/packages/core/src/modules/applications/public-api.controller.ts b/packages/core/src/modules/applications/public-api.controller.ts index 172bd20..74b59e8 100644 --- a/packages/core/src/modules/applications/public-api.controller.ts +++ b/packages/core/src/modules/applications/public-api.controller.ts @@ -124,12 +124,17 @@ export class PublicApiController { // Forward to AI service (proxy pattern) const AI_URL = process.env.AI_SERVICE_URL || "http://localhost:8000"; + const internalToken = process.env.INTERNAL_SERVICE_TOKEN; + const internalHeaders: Record = { + "Content-Type": "application/json", + ...(internalToken && { "X-Internal-Token": internalToken }), + }; let conversationId = body.conversationId; if (!conversationId) { const convRes = await fetch(`${AI_URL}/api/ai/conversations`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: internalHeaders, body: JSON.stringify({ application_id: app.id, user_id: body.sessionId || "anonymous", @@ -157,7 +162,7 @@ export class PublicApiController { `${AI_URL}/api/ai/conversations/${conversationId}/messages`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: internalHeaders, body: JSON.stringify(msgPayload), }, ); @@ -186,7 +191,12 @@ export class PublicApiController { @ApiOperation({ summary: "Get conversation history" }) async getConversation(@Param("conversationId") id: string) { const AI_URL = process.env.AI_SERVICE_URL || "http://localhost:8000"; - const res = await fetch(`${AI_URL}/api/ai/conversations/${id}`); + const internalToken = process.env.INTERNAL_SERVICE_TOKEN; + const res = await fetch(`${AI_URL}/api/ai/conversations/${id}`, { + headers: { + ...(internalToken && { "X-Internal-Token": internalToken }), + }, + }); if (!res.ok) throw new NotFoundException("Conversation not found"); return res.json(); } diff --git a/packages/core/src/modules/system-health/system-health.service.ts b/packages/core/src/modules/system-health/system-health.service.ts index 2fba30b..20a3bc9 100644 --- a/packages/core/src/modules/system-health/system-health.service.ts +++ b/packages/core/src/modules/system-health/system-health.service.ts @@ -97,9 +97,15 @@ export class SystemHealthService { const start = Date.now(); try { const aiUrl = process.env.AI_SERVICE_URL || 'http://localhost:8000'; + const internalToken = process.env.INTERNAL_SERVICE_TOKEN; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); - const res = await fetch(`${aiUrl}/api/ai/health`, { signal: controller.signal }); + const res = await fetch(`${aiUrl}/api/ai/health`, { + signal: controller.signal, + headers: { + ...(internalToken && { 'X-Internal-Token': internalToken }), + }, + }); clearTimeout(timeout); if (res.ok) { return { name: 'AI Service', status: 'healthy', latencyMs: Date.now() - start }; diff --git a/packages/core/src/modules/workflows/workflows.service.ts b/packages/core/src/modules/workflows/workflows.service.ts index 91b62e0..2347916 100644 --- a/packages/core/src/modules/workflows/workflows.service.ts +++ b/packages/core/src/modules/workflows/workflows.service.ts @@ -247,10 +247,14 @@ export class WorkflowsService { const systemPrompt = this.interpolate(config.systemPrompt || '', context); const aiUrl = this.config.get('AI_SERVICE_URL', 'http://localhost:8000'); + const internalToken = this.config.get('INTERNAL_SERVICE_TOKEN'); try { const res = await fetch(`${aiUrl}/api/ai/chat`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(internalToken && { 'X-Internal-Token': internalToken }), + }, body: JSON.stringify({ message: prompt, systemPrompt, diff --git a/packages/frontend/src/app/layout.tsx b/packages/frontend/src/app/layout.tsx index 748bab7..31ae0d5 100644 --- a/packages/frontend/src/app/layout.tsx +++ b/packages/frontend/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from 'next'; import { Providers } from './providers'; +import { Analytics } from '@/components/analytics/Analytics'; +import { CookieConsent } from '@/components/analytics/CookieConsent'; import './globals.css'; export const metadata: Metadata = { @@ -15,7 +17,11 @@ export default function RootLayout({ return ( - {children} + + {children} + + + ); diff --git a/packages/frontend/src/app/providers.tsx b/packages/frontend/src/app/providers.tsx index 93a1952..b98ba55 100644 --- a/packages/frontend/src/app/providers.tsx +++ b/packages/frontend/src/app/providers.tsx @@ -1,8 +1,13 @@ 'use client'; import { AuthProvider } from '@/context/auth-context'; +import { CookieConsentProvider } from '@/components/analytics/CookieConsentContext'; import { ReactNode } from 'react'; export function Providers({ children }: { children: ReactNode }) { - return {children}; + return ( + + {children} + + ); } diff --git a/packages/frontend/src/components/analytics/Analytics.tsx b/packages/frontend/src/components/analytics/Analytics.tsx new file mode 100644 index 0000000..d5fd04b --- /dev/null +++ b/packages/frontend/src/components/analytics/Analytics.tsx @@ -0,0 +1,50 @@ +'use client'; + +import Script from 'next/script'; +import { useCookieConsent } from './CookieConsentContext'; + +const GA_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; +const UET_ID = process.env.NEXT_PUBLIC_MS_UET_TAG_ID; + +export function Analytics() { + const { consent } = useCookieConsent(); + + if (consent !== 'accepted') return null; + + return ( + <> + {GA_ID && ( + <> + + + )} + + {UET_ID && ( + <> + + + )} + + ); +} diff --git a/packages/frontend/src/components/analytics/CookieConsent.tsx b/packages/frontend/src/components/analytics/CookieConsent.tsx new file mode 100644 index 0000000..806fb85 --- /dev/null +++ b/packages/frontend/src/components/analytics/CookieConsent.tsx @@ -0,0 +1,63 @@ +'use client'; + +import Link from 'next/link'; +import { useCookieConsent } from './CookieConsentContext'; + +export function CookieConsent() { + const { consent, accept, decline } = useCookieConsent(); + + if (consent !== 'pending') return null; + + return ( +
+
+

+ We use cookies and similar technologies to analyse traffic and improve + your experience. See our{' '} + + Cookie Policy + {' '} + and{' '} + + Privacy Policy + {' '} + for details. +

+
+ + +
+
+
+ ); +} + +/** Inline "Manage cookies" link — embed anywhere (e.g. footer). */ +export function ManageCookiesLink() { + const { decline } = useCookieConsent(); + return ( + + ); +} diff --git a/packages/frontend/src/components/analytics/CookieConsentContext.tsx b/packages/frontend/src/components/analytics/CookieConsentContext.tsx new file mode 100644 index 0000000..d1655bc --- /dev/null +++ b/packages/frontend/src/components/analytics/CookieConsentContext.tsx @@ -0,0 +1,50 @@ +'use client'; + +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; + +type ConsentState = 'pending' | 'accepted' | 'declined'; + +interface CookieConsentContextValue { + consent: ConsentState; + accept: () => void; + decline: () => void; +} + +const STORAGE_KEY = 'ab_cookie_consent'; + +const CookieConsentContext = createContext({ + consent: 'pending', + accept: () => {}, + decline: () => {}, +}); + +export function CookieConsentProvider({ children }: { children: React.ReactNode }) { + const [consent, setConsent] = useState('pending'); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY) as ConsentState | null; + if (stored === 'accepted' || stored === 'declined') { + setConsent(stored); + } + }, []); + + const accept = useCallback(() => { + localStorage.setItem(STORAGE_KEY, 'accepted'); + setConsent('accepted'); + }, []); + + const decline = useCallback(() => { + localStorage.setItem(STORAGE_KEY, 'declined'); + setConsent('declined'); + }, []); + + return ( + + {children} + + ); +} + +export function useCookieConsent() { + return useContext(CookieConsentContext); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cdd37f..d0a3c6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,12 @@ importers: packages/core: dependencies: + '@azure/identity': + specifier: ^4.5.0 + version: 4.13.1 + '@azure/storage-blob': + specifier: ^12.26.0 + version: 12.32.0 '@nestjs/axios': specifier: ^3.1.3 version: 3.1.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.13.6)(rxjs@7.8.2) @@ -69,7 +75,10 @@ importers: version: 8.1.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/typeorm': specifier: ^10.0.2 - version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))) + version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(ioredis@5.11.1)(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))) + '@types/ioredis': + specifier: ^5.0.0 + version: 5.0.0 axios: specifier: ^1.13.6 version: 1.13.6 @@ -85,6 +94,9 @@ importers: helmet: specifier: ^8.1.0 version: 8.1.0 + ioredis: + specifier: ^5.11.1 + version: 5.11.1 mongoose: specifier: ^8.9.0 version: 8.23.0 @@ -120,7 +132,7 @@ importers: version: 20.3.1(@types/node@22.19.11) typeorm: specifier: ^0.3.20 - version: 0.3.28(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 0.3.28(ioredis@5.11.1)(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) uuid: specifier: ^11.0.0 version: 11.1.0 @@ -269,7 +281,7 @@ importers: version: 10.4.24(postcss@8.5.6) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + version: 29.7.0(@types/node@22.19.11) jest-environment-jsdom: specifier: ^30.2.0 version: 30.2.0 @@ -284,7 +296,7 @@ importers: version: 1.0.7(tailwindcss@3.4.19) ts-jest: specifier: ^29.4.6 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -373,10 +385,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -392,10 +404,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -414,10 +426,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -436,10 +448,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -458,10 +470,10 @@ importers: version: 20.19.33 jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.33) ts-jest: specifier: ^29.1.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -480,10 +492,10 @@ importers: version: 20.19.33 jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.33) ts-jest: specifier: ^29.1.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -502,10 +514,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -524,10 +536,10 @@ importers: version: 20.19.33 jest: specifier: ^29.5.0 - version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + version: 29.7.0(@types/node@20.19.33) ts-jest: specifier: ^29.1.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3) typescript: specifier: ^5.0.0 version: 5.9.3 @@ -546,10 +558,10 @@ importers: version: 25.5.2 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0(@types/node@25.5.2) ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -565,10 +577,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.7.0 version: 5.9.3 @@ -586,10 +598,10 @@ importers: version: 29.5.14 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + version: 29.7.0 ts-jest: specifier: ^29.2.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3) typescript: specifier: ^5.3.0 version: 5.9.3 @@ -624,6 +636,77 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@azure/abort-controller@2.1.2': + resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} + engines: {node: '>=18.0.0'} + + '@azure/core-auth@1.10.1': + resolution: {integrity: sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==} + engines: {node: '>=20.0.0'} + + '@azure/core-client@1.10.2': + resolution: {integrity: sha512-1D2LpsU7y9xrqKjdIbsB7PlrRePw0xsVV8p+AKTlzITrWmscajryfJCdDJB/oGwvDI5HmRo04eMMADB67uwAwQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-http-compat@2.4.0': + resolution: {integrity: sha512-f1P96IB399YiN2ARYHP7EpZi3Bf3wH4SN2lGzrw7JVwm7bbsVYtf2iKSBwTywD2P62NOPZGHFSZi+6jjb75JuA==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@azure/core-client': ^1.10.0 + '@azure/core-rest-pipeline': ^1.22.0 + + '@azure/core-lro@2.7.2': + resolution: {integrity: sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==} + engines: {node: '>=18.0.0'} + + '@azure/core-paging@1.6.2': + resolution: {integrity: sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==} + engines: {node: '>=18.0.0'} + + '@azure/core-rest-pipeline@1.24.0': + resolution: {integrity: sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==} + engines: {node: '>=20.0.0'} + + '@azure/core-tracing@1.3.1': + resolution: {integrity: sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==} + engines: {node: '>=20.0.0'} + + '@azure/core-util@1.13.1': + resolution: {integrity: sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==} + engines: {node: '>=20.0.0'} + + '@azure/core-xml@1.5.1': + resolution: {integrity: sha512-xcNRHqCoSp4AunOALEae6A8f3qATb83gSrm31Iqb01OzblvC3/W/bfXozcq78EzIdzZzuH1bZ2NvRR0TdX709w==} + engines: {node: '>=20.0.0'} + + '@azure/identity@4.13.1': + resolution: {integrity: sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==} + engines: {node: '>=20.0.0'} + + '@azure/logger@1.3.0': + resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} + engines: {node: '>=20.0.0'} + + '@azure/msal-browser@5.14.0': + resolution: {integrity: sha512-Dfl7hPZe9/JJwRhFFXHq2z1oHYBuGubmff3kWXOsd1AGgyXlqjNYAWuN/1JL/ZrcZBs8TKMjGSil6Rcc7E8VPQ==} + engines: {node: '>=0.8.0'} + + '@azure/msal-common@16.9.0': + resolution: {integrity: sha512-1MWGjqgUCRAYgLmVFZKp7fs3Rg1TFvIMgywY8ze2olNVvLlJoRThuoziWSDJuwwyJI5L4rnLb9Tyt5D9GvSLPw==} + engines: {node: '>=0.8.0'} + + '@azure/msal-node@5.2.5': + resolution: {integrity: sha512-RUuewWk9JvWJS5Yiy8/74Lm1rQAWlrU/qg/Bgtk1jIauVRtnb9XKwS5Xg0J+Whwjesq9EVrBIFgQEP8vHxgezA==} + engines: {node: '>=20'} + + '@azure/storage-blob@12.32.0': + resolution: {integrity: sha512-80LzSNnFQye2LCCBFghAJS6jJQJ7N4bfgZ6qDMgVGRtugZ7TLDKQZ2hczMigmZH3jAcMRdma/IygsC5+0gT7Tw==} + engines: {node: '>=20.0.0'} + + '@azure/storage-common@12.4.0': + resolution: {integrity: sha512-kNhJKMxQb374KOVt63CZnGIpDcrKNzJeyANLJymxE9mCJSdRGzb+Iv9oSIiCj6tNMLypr530b9ObOiA/5OvwOg==} + engines: {node: '>=20.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -857,6 +940,9 @@ packages: react: ^16 || ^17 || ^18 react-dom: ^16 || ^17 || ^18 + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1342,6 +1428,9 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@nodable/entities@2.2.0': + resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1924,6 +2013,10 @@ packages: '@types/inquirer@8.2.12': resolution: {integrity: sha512-YxURZF2ZsSjU5TAe06tW0M3sL4UI9AMPA6dd8I72uOtppzNafcY38xkYgCZ/vsVOAyNdzHmvtTpLWilOrbP0dQ==} + '@types/ioredis@5.0.0': + resolution: {integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==} + deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed. + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -2073,6 +2166,10 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typespec/ts-http-runtime@0.3.6': + resolution: {integrity: sha512-jIXhD0eWQ1JA6ln/5Dltyx22UxWNrw0hZmhy2rlv6m6KgF7kplHx3g0fzi09lNmTJQRR91OlemYp3xFnvDK9og==} + engines: {node: '>=20.0.0'} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -2237,6 +2334,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + anynum@1.0.1: + resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==} + app-root-path@3.1.0: resolution: {integrity: sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==} engines: {node: '>= 6.0.0'} @@ -2414,6 +2514,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -2558,6 +2662,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -2917,6 +3025,14 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -2924,6 +3040,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -2934,6 +3054,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -3195,6 +3319,13 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.9.3: + resolution: {integrity: sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==} + hasBin: true + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3570,6 +3701,10 @@ packages: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} deprecated: The Intersection Observer polyfill is no longer needed and can safely be removed. Intersection Observer has been Baseline since 2019. + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -3602,6 +3737,11 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -3625,6 +3765,11 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + is-interactive@1.0.0: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} @@ -3666,6 +3811,13 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-unsafe@1.0.1: + resolution: {integrity: sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -4627,6 +4779,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -4704,6 +4860,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -5056,6 +5216,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -5144,6 +5312,10 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + run-async@2.4.1: resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} engines: {node: '>=0.12.0'} @@ -5345,6 +5517,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -5420,6 +5595,9 @@ packages: '@types/node': optional: true + strnum@2.4.1: + resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} @@ -6040,10 +6218,18 @@ packages: utf-8-validate: optional: true + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -6132,6 +6318,146 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@azure/abort-controller@2.1.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-auth@1.10.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-client@1.10.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-rest-pipeline': 1.24.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-http-compat@2.4.0(@azure/core-client@1.10.2)(@azure/core-rest-pipeline@1.24.0)': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-client': 1.10.2 + '@azure/core-rest-pipeline': 1.24.0 + + '@azure/core-lro@2.7.2': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-paging@1.6.2': + dependencies: + tslib: 2.8.1 + + '@azure/core-rest-pipeline@1.24.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@typespec/ts-http-runtime': 0.3.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-tracing@1.3.1': + dependencies: + tslib: 2.8.1 + + '@azure/core-util@1.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@typespec/ts-http-runtime': 0.3.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/core-xml@1.5.1': + dependencies: + fast-xml-parser: 5.9.3 + tslib: 2.8.1 + + '@azure/identity@4.13.1': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.2 + '@azure/core-rest-pipeline': 1.24.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + '@azure/msal-browser': 5.14.0 + '@azure/msal-node': 5.2.5 + open: 10.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/logger@1.3.0': + dependencies: + '@typespec/ts-http-runtime': 0.3.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/msal-browser@5.14.0': + dependencies: + '@azure/msal-common': 16.9.0 + + '@azure/msal-common@16.9.0': {} + + '@azure/msal-node@5.2.5': + dependencies: + '@azure/msal-common': 16.9.0 + jsonwebtoken: 9.0.3 + + '@azure/storage-blob@12.32.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-client': 1.10.2 + '@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.2)(@azure/core-rest-pipeline@1.24.0) + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.24.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/core-xml': 1.5.1 + '@azure/logger': 1.3.0 + '@azure/storage-common': 12.4.0(@azure/core-client@1.10.2) + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@azure/storage-common@12.4.0(@azure/core-client@1.10.2)': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.10.1 + '@azure/core-http-compat': 2.4.0(@azure/core-client@1.10.2)(@azure/core-rest-pipeline@1.24.0) + '@azure/core-rest-pipeline': 1.24.0 + '@azure/core-tracing': 1.3.1 + '@azure/core-util': 1.13.1 + '@azure/logger': 1.3.0 + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - '@azure/core-client' + - supports-color + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6378,6 +6704,8 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@ioredis/commands@1.10.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -6408,41 +6736,6 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0 - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 22.19.11 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))': dependencies: '@jest/console': 29.7.0 @@ -7001,13 +7294,13 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) - '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))': + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28(ioredis@5.11.1)(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 rxjs: 7.8.2 - typeorm: 0.3.28(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + typeorm: 0.3.28(ioredis@5.11.1)(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) uuid: 9.0.1 '@next/env@14.2.35': {} @@ -7041,6 +7334,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@nodable/entities@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7636,6 +7931,12 @@ snapshots: '@types/through': 0.0.33 rxjs: 7.8.2 + '@types/ioredis@5.0.0': + dependencies: + ioredis: 5.11.1 + transitivePeerDependencies: + - supports-color + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -7807,6 +8108,14 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@typespec/ts-http-runtime@0.3.6': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@ungap/structured-clone@1.3.0': {} '@webassemblyjs/ast@1.14.1': @@ -7989,6 +8298,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + anynum@1.0.1: {} + app-root-path@3.1.0: {} append-field@1.0.0: {} @@ -8204,6 +8515,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -8331,6 +8646,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.1: {} + co@4.6.0: {} collect-v8-coverage@1.0.3: {} @@ -8437,13 +8754,13 @@ snapshots: optionalDependencies: typescript: 5.7.2 - create-jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): + create-jest@29.7.0(@types/node@20.19.33): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@20.19.33) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -8720,6 +9037,13 @@ snapshots: deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + defaults@1.0.4: dependencies: clone: 1.0.4 @@ -8730,6 +9054,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-lazy-prop@3.0.0: {} + delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 @@ -8738,6 +9064,8 @@ snapshots: delegates@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -9010,6 +9338,20 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.9.3: + dependencies: + '@nodable/entities': 2.2.0 + fast-xml-builder: 1.2.0 + is-unsafe: 1.0.1 + path-expression-matcher: 1.5.0 + strnum: 2.4.1 + xml-naming: 0.1.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -9503,6 +9845,18 @@ snapshots: intersection-observer@0.12.2: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-alphabetical@2.0.1: {} @@ -9528,6 +9882,8 @@ snapshots: is-decimal@2.0.1: {} + is-docker@3.0.0: {} + is-extendable@0.1.1: {} is-extglob@2.1.1: {} @@ -9542,6 +9898,10 @@ snapshots: is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + is-interactive@1.0.0: {} is-number@7.0.0: {} @@ -9570,6 +9930,12 @@ snapshots: is-unicode-supported@0.1.0: {} + is-unsafe@1.0.1: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -9659,16 +10025,54 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): + jest-cli@29.7.0: dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-cli@29.7.0(@types/node@20.19.33): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.19.33) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.19.33) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-cli@29.7.0(@types/node@22.19.11): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -9697,9 +10101,9 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): + jest-cli@29.7.0(@types/node@25.5.2): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 @@ -9716,38 +10120,26 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): + jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): dependencies: - '@babel/core': 7.29.0 - '@jest/test-sequencer': 29.7.0 + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.29.0) chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0 - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0 + create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) jest-util: 29.7.0 jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.19.33 - ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3) + yargs: 17.7.2 transitivePeerDependencies: + - '@types/node' - babel-plugin-macros - supports-color + - ts-node - jest-config@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): + jest-config@29.7.0(@types/node@20.19.33): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 @@ -9772,8 +10164,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 22.19.11 - ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3) + '@types/node': 20.19.33 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -10174,12 +10565,36 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)): + jest@29.7.0: dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + jest-cli: 29.7.0 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest@29.7.0(@types/node@20.19.33): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.19.33) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest@29.7.0(@types/node@22.19.11): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@22.19.11) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -10198,6 +10613,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@25.5.2): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.5.2) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)): dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)) @@ -11240,6 +11667,13 @@ snapshots: dependencies: mimic-fn: 2.1.0 + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + ora@5.4.1: dependencies: bl: 4.1.0 @@ -11328,6 +11762,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@2.0.1: {} @@ -11671,6 +12107,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.2.2: {} rehype-katex@7.0.1: @@ -11780,6 +12222,8 @@ snapshots: rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} + run-async@2.4.1: {} run-async@3.0.0: {} @@ -11999,6 +12443,8 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} streamsearch@1.1.0: {} @@ -12059,6 +12505,10 @@ snapshots: optionalDependencies: '@types/node': 22.19.11 + strnum@2.4.1: + dependencies: + anynum: 1.0.1 + strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 @@ -12275,12 +12725,12 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)) + jest: 29.7.0(@types/node@20.19.33) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -12315,6 +12765,26 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 30.3.0 + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@22.19.11) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@jest/types': 30.3.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + jest-util: 30.3.0 + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -12335,6 +12805,46 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.29.0) jest-util: 30.3.0 + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0(@types/node@25.5.2) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@jest/types': 30.3.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + jest-util: 30.3.0 + + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 29.7.0 + '@jest/types': 30.3.0 + babel-jest: 29.7.0(@babel/core@7.29.0) + jest-util: 30.3.0 + ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -12425,7 +12935,7 @@ snapshots: typedarray@0.0.6: {} - typeorm@0.3.28(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)): + typeorm@0.3.28(ioredis@5.11.1)(mongodb@6.20.0)(pg@8.18.0)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)): dependencies: '@sqltools/formatter': 1.2.5 ansis: 4.2.0 @@ -12443,6 +12953,7 @@ snapshots: uuid: 11.1.0 yargs: 17.7.2 optionalDependencies: + ioredis: 5.11.1 mongodb: 6.20.0 pg: 8.18.0 ts-node: 10.9.2(@types/node@22.19.11)(typescript@5.9.3) @@ -12780,8 +13291,14 @@ snapshots: ws@8.19.0: {} + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xmlchars@2.2.0: {} xtend@4.0.2: {}