diff --git a/.agents/skills/scope-api/SKILL.md b/.agents/skills/scope-api/SKILL.md index 5a9ba5595..a3495804c 100644 --- a/.agents/skills/scope-api/SKILL.md +++ b/.agents/skills/scope-api/SKILL.md @@ -17,12 +17,44 @@ The `API_PORT` variable is defined in the project `.env` file (default: `3116`). ### Authentication -Most endpoints require a bearer token. Pass it as: +For authenticated calls, pass the existing **IdP access token** unchanged: ``` -H "Authorization: Bearer $SCOPE_TOKEN" ``` -If the user has the SCOPE CLI configured, the token may be available via environment or the CLI config. +Use the caller-provided `SCOPE_TOKEN` when available; never print/log the token or +put it in a URL. There is no Scope session JWT, `/auth/login`, or token exchange. +The API verifies signature/claims before consulting its user-access cache. + +**Enrollment is explicit.** Already-enrolled bearer callers remain compatible. +For a new identity, intentionally call this once before other authenticated calls: + +```bash +curl --fail-with-body -sS \ + -X POST \ + -H "Authorization: Bearer $SCOPE_TOKEN" \ + -H "Cache-Control: no-store" \ + "http://localhost:$API_PORT/api/v1/users/me" +``` + +Only POST `/api/v1/users/me` may JIT-create the user or update profile, +`lastLoginAt`, and eligible bootstrap-admin promotion. This POST has side effects: +**never prefetch or poll it**, and do not silently invoke it as an ordinary lookup +retry. `lastLoginAt` records this explicit upsert, not proof of an interactive login. +For normal identity checks use GET `/api/v1/users/me`; Invalid/repeated/structured +login values return `400`; HEAD never enrolls. + +Plain `/me` and other authenticated routes resolve an existing active Scope UUID/role +from Redis; miss/unavailability reads Mongo by exact `(idp, tid, oid)` and warms the +cache, without JIT. Missing users return `403 user_not_enrolled`, disabled users +`403 user_disabled`, reserved/invalid principals `401`; required Mongo/JWKS outages +return `503`, unexpected errors `500`. Do not interpret a cache miss as a denial or +turn a verified-identity denial into anonymous access. + +The fixed/non-sliding cache TTL (`AUTH_USER_CACHE_TTL_SECONDS`, default 300 seconds) +means DB-only role/disable changes may not be visible until expiry. Public endpoints +and existing no-token/auth-disabled anonymous rollout remain supported; this is not +full route RBAC. See [the auth contract](../../../docs/architecture/auth-rbac.md). ### Before Making API Calls @@ -45,6 +77,7 @@ curl -s http://localhost:$API_PORT/openapi.json | jq '.paths["/api/v1/requests"] ### Key Endpoint Groups - **System**: `/health`, `/ready`, `/about`, `/api/v1/version` +- **Identity**: GET `/api/v1/users/me` (read-only); POST `/api/v1/users/me` (explicit enrollment/login refresh) - **Requests & Runs**: `/api/v1/requests/*` (create, cancel, retry, pause, resume, bulk ops, logs, HAR, video, snapshots, tool-calls) - **Skills**: `/api/v1/skills/*` (discover, search, external, resolve, revisions) - **Agents**: `/api/v1/agents/*` (CRUD, versions) diff --git a/.agents/skills/scope-cli/SKILL.md b/.agents/skills/scope-cli/SKILL.md index ed78b8899..b68d542de 100644 --- a/.agents/skills/scope-cli/SKILL.md +++ b/.agents/skills/scope-cli/SKILL.md @@ -16,6 +16,40 @@ Run it with `pnpm cli` from the repository root (`scope-core`). --- +## Authentication and enrollment + +The CLI's shared API transport can attach a caller-provided `SCOPE_TOKEN` containing +an **IdP access token**. Already-enrolled users keep using that bearer unchanged; +there is no Scope-token exchange or new CLI login implementation in this milestone. +Do not assume the deferred `scope auth login`/keychain commands exist. + +A new identity must explicitly call **`POST /api/v1/users/me`** using its +IdP bearer before ordinary authenticated commands. Use the configured Scope API URL: + +```bash +curl --fail-with-body -sS \ + -X POST \ + -H "Authorization: Bearer $SCOPE_TOKEN" \ + -H "Cache-Control: no-store" \ + "${SCOPE_API_URL%/}/api/v1/users/me" +``` + +This POST creates/refreshes the user, profile, `lastLoginAt`, and eligible bootstrap +promotion; never prefetch, poll, or automatically use it to recover an ordinary +lookup. GET `/users/me` only checks existing access. `403 user_not_enrolled` calls +for explicit enrollment; `403 user_disabled` is a denial, not a refresh-token prompt. +Invalid/expired bearer → `401`; required Mongo/JWKS outage → `503`. + +All non-public bearer calls verify the IdP token before Redis/Mongo resolution. +The active-user cache is fixed/non-sliding (300 seconds by default), so DB-only +role/disable edits may remain stale until expiry. No raw bearer is cached by the API. +Never echo tokens or include them in URLs/debug output. Public and anonymous rollout +behavior is unchanged; full RBAC and interactive CLI auth remain deferred. + +See [Authentication & RBAC](../../../docs/architecture/auth-rbac.md). + +--- + ## Quick Reference In order to get the full updated reference, run `pnpm cli --help` or `pnpm cli --help` for specific commands. Below is a summary of the most common commands. diff --git a/.dockerignore b/.dockerignore index 2e8ccf53a..cbcbab1dd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -29,6 +29,9 @@ ctrf .env.* !.env.example +# Local development TLS material is mounted at runtime, never built into images. +.certs + # Documentation (not needed in images) docs diff --git a/.env.base b/.env.base index efc456520..9cce9af50 100644 --- a/.env.base +++ b/.env.base @@ -22,6 +22,9 @@ AZURITE_BLOB_PORT=10100 AZURITE_QUEUE_PORT=10200 AZURITE_TABLE_PORT=10300 API_PORT=3100 +# Host-side Node inspector port for the API in docker-compose.dev.yml. +# The inspector is available only on localhost; the container uses port 9229. +API_DEBUG_PORT=9200 # entra-local (dev auth emulator) host port. Offset per worktree so each # composition gets a unique host port; the advertised origin (PUBLIC_ORIGIN) # and MSAL authority follow this port. Container still binds 8443 internally. diff --git a/.env.example b/.env.example index 3a76f37cf..916bc1e40 100644 --- a/.env.example +++ b/.env.example @@ -119,3 +119,51 @@ FEEDBACK_MAX_CRITERIA=1 # Whether to guard against hinting about descendant criteria (default: true) # Prevents feedback from mentioning requirements not yet introduced FEEDBACK_DESCENDANT_GUARD=true + +# --------------------------------------------------------------------------- +# API Authentication (Microsoft Entra ID) — identity-only, non-breaking +# --------------------------------------------------------------------------- +# Leave AUTH_PROVIDER unset to keep auth disabled: the API boots and treats +# every caller as anonymous when all IdP settings are absent. Configured clients +# enroll through GET /api/v1/users/me?login=true, then use IdP bearer tokens on +# ordinary requests. Active Scope users/roles are cached in Redis. +# See ENV_VARIABLES.md → "API Authentication" for details. + +# Identity provider. Set to `entra` to enable token verification. +# AUTH_PROVIDER=entra + +# OIDC authority for JWKS discovery + issuer validation (multi-tenant example). +# Point at the entra-local emulator for offline development. +# AUTH_AUTHORITY=https://login.microsoftonline.com/common + +# Per-tenant issuer template ({tenantid} is substituted from the token `tid`). +# Override only for a self-hosted issuer, e.g. the entra-local emulator: +# https://localhost:8443/{tenantid}/v2.0 +# AUTH_ISSUER_TEMPLATE=https://login.microsoftonline.com/{tenantid}/v2.0 + +# Explicit JWKS URI. Leave unset to derive it as /discovery/v2.0/keys. +# Every signing key must publish an `issuer` matching the token issuer (with +# `{tenantid}` substitution supported); missing issuer metadata is rejected. +# AUTH_JWKS_URI= + +# The API App Registration (client) ID — verified as the token audience (aud). +# AUTH_API_CLIENT_ID=00000000-0000-0000-0000-000000000000 + +# Public client ID advertised to the CLI for interactive sign-in. +# AUTH_CLI_CLIENT_ID=00000000-0000-0000-0000-000000000000 + +# Public client ID advertised to the Portal for interactive sign-in. +# AUTH_PORTAL_CLIENT_ID=00000000-0000-0000-0000-000000000000 + +# Scopes the CLI/Portal request for the API access token. +# AUTH_SCOPES=api://00000000-0000-0000-0000-000000000000/access + +# Promote-only admin bootstrap: comma-separated `${idp}:${tenant}/${subject}`. +# AUTH_BOOTSTRAP_ADMINS= + +# Required tenant allowlist when AUTH_BOOTSTRAP_ADMINS is configured. +# AUTH_BOOTSTRAP_TENANTS= + +# Fixed active-user cache lifetime, in seconds (positive integer, default 300). +# Cache hits do not extend expiry; Redis outages fall back to MongoDB. +# AUTH_USER_CACHE_TTL_SECONDS=300 diff --git a/.env.local.example b/.env.local.example index 58d2066fb..e412458e0 100644 --- a/.env.local.example +++ b/.env.local.example @@ -42,3 +42,74 @@ AZURE_AI_INFERENCE_API_KEY= # Defaults to gpt-4.1 inside the API when unset. # --------------------------------------------------------------------------- LLM_MODEL= + +# --------------------------------------------------------------------------- +# API Authentication — cmaneu/entra-local emulator defaults +# +# Ready-to-use values for local sign-in against the `entra-local` emulator +# (https://github.com/cmaneu/entra-local). Uncomment the block below to have +# the API verify Entra ID access tokens minted by the emulator and attach +# `req.user` to each request. Leave it commented to keep auth disabled (the API +# still boots and treats every caller as anonymous). See ENV_VARIABLES.md → +# "API Authentication" for the full variable reference. +# +# Start the emulator (HTTPS on :8443, seeded tenant + apps + users): +# docker pull ghcr.io/cmaneu/entra-local +# docker run -p 8443:8443 -v entra-local-data:/app/data ghcr.io/cmaneu/entra-local +# +# The emulator serves a self-signed certificate. `jose` fetches the JWKS over +# HTTPS, so for local dev either trust the emulator CA or, as a quick shortcut, +# set NODE_TLS_REJECT_UNAUTHORIZED=0 for the api service (dev-only — never in +# production). The emulator advertises issuer + JWKS under +# https://localhost:8443//v2.0. +# +# Seeded identities (see the entra-local docs): +# tenant 11111111-1111-1111-1111-111111111111 +# API app cccccccc-0000-0000-0000-000000000005 (exposes access_as_user) -> token `aud` +# SPA app cccccccc-0000-0000-0000-000000000001 (public client, redirect https://localhost:3000) +# user alice@entralocal.dev -> oid aaaaaaaa-0000-0000-0000-000000000001 (bootstrap candidate below) +# user bob@entralocal.dev -> oid aaaaaaaa-0000-0000-0000-000000000002 +# --------------------------------------------------------------------------- +# AUTH_PROVIDER=entra + +# OIDC authority — the emulator issuer base for the seeded tenant. In a worktree +# with the normal port offset, this is usually `https://localhost:8501/...` not +# the base `:8443` emulator port exposed in the upstream docs. +# AUTH_AUTHORITY=https://localhost:8501/11111111-1111-1111-1111-111111111111 + +# Issuer template — REQUIRED for entra-local, since its issuer differs from the +# Entra-cloud default (`https://login.microsoftonline.com/{tenantid}/v2.0`). +# `{tenantid}` is substituted from each token's `tid` claim. +# AUTH_ISSUER_TEMPLATE=https://localhost:8501/{tenantid}/v2.0 + +# The API App Registration (client) ID — verified as the token audience (aud). +# For entra-local this is the seeded resource API app that exposes access_as_user. +# AUTH_API_CLIENT_ID=cccccccc-0000-0000-0000-000000000005 + +# Public client ID advertised to the CLI for interactive sign-in. +# AUTH_CLI_CLIENT_ID=cccccccc-0000-0000-0000-000000000001 + +# Public client ID advertised to the Portal for interactive sign-in. +# AUTH_PORTAL_CLIENT_ID=cccccccc-0000-0000-0000-000000000001 + +# Scope the CLI/Portal request when acquiring an API access token. +# AUTH_SCOPES=api://cccccccc-0000-0000-0000-000000000005/access_as_user + +# Optional explicit JWKS URI. Docker Compose defaults this to the emulator's +# internal address (`https://entra-local:8443/.../keys`). For a native API +# process, use the worktree's host-facing ENTRA_LOCAL_PORT instead. Scope +# requires every signing key to publish its per-tenant `issuer`; use an updated +# entra-local image that includes this JWKS extension. +# AUTH_JWKS_URI=https://localhost:8501/11111111-1111-1111-1111-111111111111/discovery/v2.0/keys + +# Promote-only admin bootstrap: comma-separated `${idp}:${tenant}/${subject}`. +# GET /api/v1/users/me?login=true promotes alice only if her API access token +# asserts email_verified=true; otherwise the default role remains `user`. +# AUTH_BOOTSTRAP_ADMINS=entra:11111111-1111-1111-1111-111111111111/aaaaaaaa-0000-0000-0000-000000000001 + +# Required tenant allowlist when AUTH_BOOTSTRAP_ADMINS is configured. +# AUTH_BOOTSTRAP_TENANTS=11111111-1111-1111-1111-111111111111 + +# Fixed active-user Redis cache lifetime; normal requests never update lastLoginAt. +# Redis keys are namespaced by MONGO_DATABASE and verified provider/tid/oid. +# AUTH_USER_CACHE_TTL_SECONDS=300 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ca9cd4e7..3b45b86a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1030,7 +1030,16 @@ jobs: target: ${{ matrix.image.target || '' }} push: true tags: ${{ steps.tags.outputs.tags }} - build-args: ${{ steps.versions.outputs.build_args }} + build-args: | + ${{ steps.versions.outputs.build_args }} + VITE_AUTH_CLIENT_ID=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_CLIENT_ID || '' }} + VITE_AUTH_AUTHORITY=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_AUTHORITY || '' }} + VITE_AUTH_KNOWN_AUTHORITIES=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_KNOWN_AUTHORITIES || '' }} + VITE_AUTH_SCOPES=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_SCOPES || '' }} + VITE_AUTH_PROTOCOL_MODE=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_PROTOCOL_MODE || '' }} + VITE_AUTH_REDIRECT_URI=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_REDIRECT_URI || '' }} + VITE_AUTH_POST_LOGOUT_REDIRECT_URI=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_POST_LOGOUT_REDIRECT_URI || '' }} + VITE_AUTH_CACHE_LOCATION=${{ matrix.image.name == 'portal' && vars.VITE_AUTH_CACHE_LOCATION || '' }} cache-from: type=gha,scope=${{ matrix.image.name }},ignore-error=true cache-to: type=gha,mode=max,scope=${{ matrix.image.name }} diff --git a/.vscode/launch.json b/.vscode/launch.json index babdf21df..b743138e2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,6 +12,30 @@ ], "cwd": "${workspaceFolder}", "console": "integratedTerminal" + }, + { + "name": "Attach API (Docker)", + "type": "node", + "request": "attach", + "address": "localhost", + "port": "${input:apiDebugPort}", + "localRoot": "${workspaceFolder}", + "remoteRoot": "/app", + "sourceMaps": true, + "restart": true, + "timeout": 30000, + "skipFiles": [ + "/**", + "${workspaceFolder}/**/node_modules/**" + ] + } + ], + "inputs": [ + { + "id": "apiDebugPort", + "type": "promptString", + "description": "API_DEBUG_PORT from this worktree's .env file", + "default": "9200" } ] } diff --git a/AGENTS.md b/AGENTS.md index 90d82632c..7b5d9edad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,15 @@ Express.js REST server. Orchestrates runs, streams logs via SSE, manages criteri - SSE + Change Streams pattern: [docs/research/realtime-data-flow.md](docs/research/realtime-data-flow.md) - Environment variables: [ENV_VARIABLES.md](ENV_VARIABLES.md) +> **Authentication invariant:** verify the unchanged IdP bearer before any user-access +> cache lookup. Only `POST /api/v1/users/me` calls +> `UserAccessResolver.enrollOnLogin()` for JIT/profile/lastLogin/bootstrap writes. +> Every `GET /users/me` and other routes use `resolveExisting()` (Redis hit: no Mongo; +> miss/outage: exact identity read, never upsert). Missing/disabled identities deny +> access, never become anonymous. Preserve existing no-token/public rollout; full +> RBAC and Scope internal tokens remain deferred. See +> [auth-rbac.md](docs/architecture/auth-rbac.md) before changing this boundary. + ### Workers (`apps/workers/`) Each worker implements the same queue-processor interface but adapts a different coding agent: @@ -76,6 +85,12 @@ Evaluation engine that scores agent output against a criteria DAG (directed acyc React 19 web UI with Vite, Tailwind CSS, Radix UI (shadcn/ui), TanStack Query, and XYFlow for criteria DAG visualization. Communicates with the API via REST and SSE. +`AuthProvider` owns the Scope-user handshake: callback → `POST /users/me`; +cached-account reload → plain `/users/me`. Gate all eager queries (including +providers outside `RequireAuth`) until ready; do not treat MSAL account claims as +the Scope UUID/role. Deduplicate account/login work and cancel it on account change +or logout. The enrollment POST is no-store and must never be prefetched/polled. + - Real-time data flow: [docs/research/realtime-data-flow.md](docs/research/realtime-data-flow.md) > **Storybook**: When adding or modifying portal components, update the corresponding Storybook stories. Use the `storybook` skill for guidance. @@ -217,6 +232,7 @@ not open the PR against the fork unless the user explicitly asks you to. | [docs/architecture/overview.md](docs/architecture/overview.md) | System architecture, component interactions, data flow | | [docs/architecture/app-design.md](docs/architecture/app-design.md) | Data models, API design, package dependency graph | | [docs/architecture/data-organization-projects.md](docs/architecture/data-organization-projects.md) | Projects (a single container) to isolate/group data within a cluster; composes with data-tags and auth-rbac | +| [docs/architecture/auth-rbac.md](docs/architecture/auth-rbac.md) | Explicit-login IdP auth, Redis user-access cache, Portal handshake; deferred RBAC/internal-token roadmap | | [docs/architecture/vscode-web-worker.md](docs/architecture/vscode-web-worker.md) | XState chat machine, GitHub auth flow, ARIA snapshots | | [docs/architecture/token-manager.md](docs/architecture/token-manager.md) | Token storage, validation, round-robin distribution | | [docs/architecture/criteria-provider.md](docs/architecture/criteria-provider.md) | CriteriaProvider abstraction, filesystem vs REST backends | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2adb0ef22..56dc07270 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,22 @@ pnpm dev:coder-acp-copilot # A single worker natively (pnpm dev: Authentication only — there is no authorization (roles/permissions) yet, and -> the API does not verify the token yet. The token is attached to API requests -> and the app is gated client-side; identity shown in the UI is derived from the -> MSAL account token claims. +**Docker builds:** the Portal Dockerfile accepts `VITE_AUTH_CLIENT_ID`, +`VITE_AUTH_AUTHORITY`, `VITE_AUTH_KNOWN_AUTHORITIES`, `VITE_AUTH_SCOPES`, +`VITE_AUTH_PROTOCOL_MODE`, `VITE_AUTH_REDIRECT_URI`, +`VITE_AUTH_POST_LOGOUT_REDIRECT_URI`, and `VITE_AUTH_CACHE_LOCATION` as build +arguments in its `builder` stage. Vite embeds them during `pnpm --filter portal +build`; setting these variables only on the final nginx container has no effect. +These are public client settings, not secrets; never pass client secrets or +access tokens as Portal build arguments. + +- **Local Compose:** `pnpm docker:up:portal` forwards the settings from your shell + or `.env.local` through `portal.build.args`, using the seeded emulator and + per-worktree ports by default. `pnpm docker:dev:portal` supplies the same + settings to the Vite dev-server environment instead. +- **CI:** the Portal image build in `.github/workflows/ci.yml` forwards the + same-named GitHub Actions configuration variables (`vars.VITE_AUTH_*`). + Configure at least the SPA client ID, authority, and the API's exposed scope + before building an image intended for authenticated use. No GitHub variable + values are provisioned by the workflow itself. +- **Direct Docker build:** supply the settings with `--build-arg`, for example: + + ```bash + docker build -f apps/portal/Dockerfile -t scope-portal \ + --build-arg VITE_AUTH_CLIENT_ID="" \ + --build-arg VITE_AUTH_AUTHORITY="https://login.microsoftonline.com/" \ + --build-arg VITE_AUTH_SCOPES="api:///access_as_user" . + ``` + +Rebuild and recreate the Portal after changing IdP settings. A single image +promoted between environments retains the same IdP settings; only +`SCOPE_AUTH_ENABLED` remains a runtime auth switch. Empty optional redirect +arguments retain the current Portal origin, while empty protocol/cache settings +retain their documented defaults. For Entra cloud through local Compose, also +set `VITE_AUTH_PROTOCOL_MODE=AAD` and `VITE_AUTH_KNOWN_AUTHORITIES=` to override +the emulator-specific defaults. + +> The API verifies the IdP token on every non-public authenticated request, then +> resolves an active Scope user. Full route RBAC/ownership enforcement is still +> deferred. After a redirect callback, the Portal's first Scope API request is +> `POST /api/v1/users/me`; after a cached-account reload it is +> `GET /users/me`. `AuthContext` takes the Scope UUID and role from that response, not +> MSAL account claims. All eager queries, including feature flags, wait for it. ### ⚠️ IMPORTANT — Feature toggle (3 per-environment controls) @@ -358,12 +407,12 @@ per-environment controls** — one each for **local dev**, **integration**, and **production**. It is **ON by default (secure by default)** in every environment; a control must **explicitly** opt out. -> **Turn auth OFF until the API ships token verification.** The API does not yet -> validate bearer tokens. Until it does, any environment that runs the auth-gated -> Portal against that API should disable auth **for that environment only** (see -> the table). Flip it back on (or remove the override) once the auth-enabled API -> is deployed there. Because the three controls are independent, you can, for -> example, keep auth on locally while it stays off in integration and production. +> **Coordinate API and Portal rollout per environment.** The API in this branch +> verifies bearer tokens and implements the explicit-login handshake. Do not infer +> a deployed environment's version or flag state from the source tree. Enable +> Portal auth after deploying/configuring the compatible API and verifying +> POST `/users/me` followed by GET `/me`. These controls are independent +> across environments and do not turn on global API lockdown. When auth is disabled the Portal behaves **exactly as it did before auth existed**: no sign-in gate, no account menu, and no `Authorization` header on API @@ -380,8 +429,8 @@ promoted image — uses a build-time flag. | Environment | Control | Kind | Where to set | Default | | --- | --- | --- | --- | --- | | **Local dev** | `VITE_AUTH_ENABLED_LOCAL` | build-time (`import.meta.env.DEV`) | `docker-compose.dev.yml` or your shell | `true` | -| **Integration** | `SCOPE_AUTH_ENABLED` | runtime (container env) | integration portal deployment env | `true` (default); currently `false` | -| **Production** | `SCOPE_AUTH_ENABLED` | runtime (container env) | production portal deployment env | `true` (default); currently `false` | +| **Integration** | `SCOPE_AUTH_ENABLED` | runtime (container env) | integration portal deployment env | `true`; verify deployed override | +| **Production** | `SCOPE_AUTH_ENABLED` | runtime (container env) | production portal deployment env | `true`; verify deployed override | **Type:** boolean-ish string. `true`/`1`/`yes`/`on` enable; `false`/`0`/`no`/`off` disable (case-insensitive). Any other/unset value falls back to the secure @@ -403,8 +452,8 @@ so local always falls through to the Vite flag. (or your shell) to skip sign-in while iterating on UI, without standing up `entra-local`. - **Integration / production:** set `SCOPE_AUTH_ENABLED=false` on the portal - Deployment in that environment's overlay (currently `false` in both until the - API verifies tokens). No image rebuild is needed — it takes effect on the next + Deployment in that environment's overlay when an anonymous rollout is intended. + No image rebuild is needed — it takes effect on the next pod start. ### Local dev setup (entra-local) @@ -416,25 +465,64 @@ that starts the Portal (e.g. `pnpm docker:dev:copilot`, `pnpm docker:dev:portal` 1. Ensures a locally-trusted TLS cert exists via **mkcert** (`scripts/ensure-dev-certs.sh`, invoked by `scripts/dev-compose.sh`). mkcert installs a local root CA into the - OS/browser trust store and mints `.certs/entra-local.pem` for `localhost`, so - `https://localhost:` is trusted with no cert warning. MSAL + OS/browser trust store and mints `.certs/entra-local.pem` for `localhost`, + loopback IPs, and the Compose hostname `entra-local`, so + `https://localhost:` is trusted with no cert warning. Older + localhost-only certificates, certificates nearing expiry, and certificates + signed by a different CA are regenerated automatically. The public CA is + exported to `.certs/rootCA.pem`; the CA private key is never copied. MSAL requires the authority to be served over HTTPS, which is why the emulator uses TLS rather than plain HTTP. -2. Starts the `entra-local` emulator (compose `auth` profile, added automatically +2. Stages the public CA into a separate `entra_local_ca` volume. The API, + emulator health check, and redirect-registration helper mount it read-only + and use `NODE_EXTRA_CA_CERTS=/ca/rootCA.pem`. The API never mounts the + emulator's private key, and TLS certificate verification stays enabled for + both local and external HTTPS calls. +3. Starts the `entra-local` emulator (compose `auth` profile, added automatically by the dev scripts). `PUBLIC_ORIGIN`/`ISSUER` are pinned to `https://localhost:${ENTRA_LOCAL_PORT}` so the OIDC discovery document's `issuer`/endpoints use the host-facing port (the container binds `8443` internally; per-worktree port offsets would otherwise leak into the issuer and fail MSAL's authority match). -3. Runs the one-shot `entra-local-init` service, which waits for the emulator to +4. Runs the one-shot `entra-local-init` service, which waits for the emulator to become healthy and idempotently registers `http://localhost:${PORTAL_PORT}` as a `spa` redirect URI on the seeded Sample SPA app (the seed ships only `https://localhost:3000`, and each worktree gets its own `PORTAL_PORT`). -**Prerequisite:** [mkcert](https://github.com/FiloSottile/mkcert) must be -installed (`brew install mkcert nss`). The first run triggers `mkcert -install`, +**Prerequisites:** [mkcert](https://github.com/FiloSottile/mkcert) and `openssl` +must be installed (`brew install mkcert nss` on macOS, with `openssl` available +on `PATH`). The first run triggers `mkcert -install`, which asks for your password once to add the local CA to the system trust store. -This is the only interactive step. +For a browser using that same trust store, this is the only interactive step. + +For WSL, remote development, or an integrated browser with a separate trust +store, trust `.certs/rootCA.pem` on the machine or in the browser that opens the +Portal as well. `mkcert -install` in the development shell cannot configure a +different browser host. An `ERR_CERT_AUTHORITY_INVALID` error when MSAL fetches +the emulator's discovery document means that browser-side trust is still +missing; do not work around it by disabling TLS verification. Import only the +public CA certificate, never `rootCA-key.pem` or the emulator private key. + +For a Windows browser with a WSL development shell, run +`wslpath -w "$PWD/.certs/rootCA.pem"` in WSL to obtain the Windows path, then +use an interactive Windows PowerShell session: + +```powershell +Import-Certificate -FilePath "" -CertStoreLocation Cert:\CurrentUser\Root +``` + +Review and approve the Windows certificate confirmation, then reload the Portal +(restart the browser if it still caches the old trust result). This trusts +certificates signed by the development CA for the current Windows user, not +only the Scope certificate; it does not import a private key or require a +machine-wide trust change. + +The CA initializer is optional when the `auth` profile is not enabled, so +cloud-only Compose setups do not require mkcert. Set `NODE_EXTRA_CA_CERTS=` in +that case to use only Node's normal CA trust store and avoid a missing-local-CA +warning on a fresh volume. After rotating the local CA, recreate the API and +emulator containers and update browser-side CA trust: Node reads extra CA +certificates only at process startup. Then open the Portal at `http://localhost:${PORTAL_PORT}`, click **Log in**, and sign in with a seeded user (`alice@entralocal.dev` / `bob@entralocal.dev`). @@ -468,7 +556,7 @@ Hosts MSAL is allowed to talk to for non-Microsoft (custom OIDC) authorities. Required for entra-local; typically unset for production Entra. ### VITE_AUTH_SCOPES -**Default (dev):** `api://cccccccc-0000-0000-0000-000000000001/access_as_user` +**Default (dev):** `api://cccccccc-0000-0000-0000-000000000005/access_as_user` **Type:** comma-separated scope list Scopes requested for the API access token (in addition to `openid`/`profile`, @@ -502,7 +590,9 @@ Where MSAL navigates after sign-out. **Default:** `localStorage` **Type:** `localStorage` | `sessionStorage` -Where MSAL persists its token cache. +Where MSAL persists its **IdP token** cache. This is unrelated to the API's Redis +user-access cache (`AUTH_USER_CACHE_TTL_SECONDS`). No Scope session token or +additional browser bearer store is introduced. ## Portal Runtime Configuration @@ -711,6 +801,199 @@ How far the queue-processor pushes out a duplicate message's visibility when the TTL applied to per-run liveness heartbeat keys in Redis (`run-heartbeat:`). The TTL is refreshed on every beat (every 15s), so the key only expires when the worker stops beating. Set comfortably above `SCOPE_RUN_HEARTBEAT_STALE_MS` so a brief beat delay never causes premature TTL expiry; the default gives 2.5× the staleness threshold. +## API Authentication + +The API verifies Microsoft Entra ID access-token signature/claims **before any +Redis/Mongo user lookup**. Every authenticated call keeps using the same IdP +bearer; there is no `/auth/login`, token exchange, Scope JWT, or signing secret. +`req.user` contains the resolved Scope UUID and database role, not an IdP role. + +Only **`POST /api/v1/users/me`** creates missing users or refreshes +profile, `lastLoginAt`, and bootstrap-admin promotion. The upsert still precedes +the disabled-user check: an explicit login can update those fields before returning +`403`. `lastLoginAt` is the explicit upsert timestamp, not general activity or +trustworthy proof of an interactive sign-in. Every GET `/me` and other +routes read an existing active user from Redis, falling back to an exact +`(idp, tid, oid)` Mongo lookup on miss/unavailability; they never JIT or refresh profile. +Invalid/repeated/structured login values return `400`; HEAD never enrolls. + +The `/users/me` responses, including failures, are **`Cache-Control: no-store`**. +Clients also use no-store; the enrollment POST has side effects and must never be +prefetched or polled. New CLI/raw-bearer identities must deliberately enroll through +it; already-enrolled callers remain compatible without any token change. + +With auth unconfigured, the API still boots and uses the existing anonymous rollout, +so unauthenticated workers keep working. Partial auth configuration fails startup. +With auth configured, missing tokens remain anonymous except where a route requires +identity (`/users/me`); existing public exclusions remain unchanged. A verified +identity is never silently downgraded to anonymous: missing users are +`403 user_not_enrolled`, disabled users `403 user_disabled`, and the reserved `system` +principal `401`. Invalid/expired tokens on non-public routes are rejected with `401` +before cache access. Required Mongo/JWKS unavailability returns `503`; unexpected +implementation/database errors use the logged `500` path. + +Full route RBAC/ownership enforcement is **not** part of this milestone. See +[the auth flow and method walkthrough](docs/architecture/auth-rbac.md#3-api-authentication-middleware). + +### AUTH_USER_CACHE_TTL_SECONDS +**Default:** `300` (only when unset) +**Type:** positive safe integer (seconds) +**Scope:** API + +Fixed, **non-sliding** lifetime of an existing active user's Redis snapshot. +Blank, zero, negative, fractional, nonnumeric, or unsafe values fail startup, +even when IdP auth is disabled. +This variable alone does **not** enable IdP authentication or count as partial +IdP configuration. Explicit login or successful Mongo fallback writes using +atomic `SET ... EX `; hits only `GET` and never extend expiry. + +Redis uses existing `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`, and `REDIS_TLS`. +A missing/blank Redis host creates no cache client and logs rate-limited +unavailability; existing-user resolution falls back to Mongo. +The key is +`auth-user:v1::::`, +with each component independently encoded using `encodeURIComponent`. The namespace +is the configured MongoDB database name: independent databases sharing Redis must +use distinct database names/namespaces (or separate Redis instances). No new Redis +deployment or namespace secret is needed; verify this isolation in external +deployment overlays before rollout. + +The repository's `.env.example`, `.env.local.example`, and API Compose environment +wire this TTL setting. No additional tracked deployment manifests with auth +configuration were found here; externally managed overlays and deployed values +must be checked separately. + +Only validated active human principals are cached; no missing/disabled negative +entries, raw bearer tokens, or IdP-derived permissions. Invalid/mismatched payloads +are logged and evicted best-effort. Expected cache read/write/delete failures are +rate-limited in logs without tokens/PII and fall back to Mongo; recovery restores +caching. Connection/command waits are bounded, with no offline command queuing/replay +or process-local authorization cache. Cache-write failure does not discard a +successful Mongo result; required Mongo failure still fails the request. + +Database-only role/disable edits can stay stale until TTL expiry. Future mutation +endpoints must evict this key; logout is not cache invalidation. No deployment-wide +immediate revocation guarantee is implied by this cache. + +### AUTH_PROVIDER +**Default:** (not set) +**Type:** string (`entra`) + +Selects the identity-provider implementation. Set to `entra` with the required +authority/audience settings to enable verification. When all IdP auth settings +are unset, auth is disabled and callers remain anonymous; setting other IdP +settings without `AUTH_PROVIDER` fails startup rather than disabling verification. + +### AUTH_AUTHORITY +**Type:** URL string — **Required when `AUTH_PROVIDER` is set** + +OIDC authority used to discover the JWKS (signing keys) and validate the token +issuer. For multi-tenant Entra apps this is typically +`https://login.microsoftonline.com/common`. Point it at the `entra-local` +emulator for offline development +(e.g. `https://localhost:8443/`). The JWKS URI is derived as +`/discovery/v2.0/keys` unless `AUTH_JWKS_URI` is set. Scope +retains `jose`'s remote key caching and rollover behavior while additionally +validating the selected key's Entra-specific `issuer` metadata. + +### AUTH_ISSUER_TEMPLATE +**Default:** `https://login.microsoftonline.com/{tenantid}/v2.0` +**Type:** URL template string with a `{tenantid}` placeholder + +Per-tenant issuer the token's `iss` claim must match; `{tenantid}` is substituted +from each token's `tid`. Override this for a self-hosted issuer whose URL differs +from Entra cloud — e.g. the `entra-local` emulator uses +`https://localhost:8443/{tenantid}/v2.0`. Verification stays multi-tenant: any +tenant is accepted as long as its issuer matches this template. The selected +JWK's required `issuer` uses the same substitution rule when it contains +`{tenantid}`; otherwise it must exactly match the token issuer. + +### AUTH_JWKS_URI +**Default:** derived as `/discovery/v2.0/keys` +**Type:** URL string + +Explicit JWKS (signing keys) endpoint. Set this only when the JWKS URL cannot be +derived from `AUTH_AUTHORITY`. The `entra-local` emulator's default JWKS +(`/discovery/v2.0/keys`) already matches the derivation, so this is +usually left unset. Every selected key must contain a non-empty string `issuer`; +missing, malformed, ambiguous, or mismatched key issuer metadata rejects the +token. + +> **entra-local compatibility prerequisite.** At the time this validation was +> introduced, the external `cmaneu/entra-local` JWKS omitted the `issuer` +> extension. Auth-enabled local development therefore requires an emulator +> version that publishes the configured per-tenant issuer on every signing key. +> This Scope change does not modify the external emulator. + +> **Local dev TLS.** Compose trusts the emulator's mkcert CA through a read-only +> public-CA mount and `NODE_EXTRA_CA_CERTS`; the certificate covers the internal +> `entra-local` hostname as well as `localhost`. TLS verification remains enabled, +> including for external requests. Do not set `NODE_TLS_REJECT_UNAUTHORIZED=0`. +> +> For a native API process, run `scripts/ensure-dev-certs.sh`, then launch with +> `NODE_EXTRA_CA_CERTS="$PWD/.certs/rootCA.pem" pnpm dev:api`. Use the host-facing +> `https://localhost://discovery/v2.0/keys` as +> `AUTH_JWKS_URI`, not the Compose-only `entra-local` hostname. Remove any old +> `NODE_TLS_REJECT_UNAUTHORIZED=0` override from the shell or local env files. + +### AUTH_API_CLIENT_ID +**Type:** string (GUID) — **Required when `AUTH_PROVIDER` is set** + +The API's App Registration (client) ID. Verified as the token `aud` (audience) +so tokens minted for other applications are rejected. + +### AUTH_CLI_CLIENT_ID +**Type:** string (GUID) + +The public client ID reserved for future CLI interactive sign-in configuration; +not used by API verification and not served by an `/auth/config` endpoint. + +### AUTH_PORTAL_CLIENT_ID +**Type:** string (GUID) + +Reserved client-configuration metadata; not used by API verification. The current +Portal uses build-time `VITE_AUTH_CLIENT_ID`, not this API setting or an +`/auth/config` endpoint. + +### AUTH_SCOPES +**Default:** (empty) +**Type:** comma/space-separated string + +Scopes intended for clients acquiring an API access token +(e.g. `api:///access`). Not consumed by API verification or +served by a config endpoint; configure the current Portal through `VITE_AUTH_SCOPES`. + +### AUTH_BOOTSTRAP_ADMINS +**Default:** (empty) +**Type:** comma-separated list of identity keys + +Identities to promote to the `admin` role on explicit POST `/users/me` +enrollment or subsequent login refresh, formatted as +`${idp}:${idpTenant}/${idpSubject}` (e.g. +`entra:00000000-0000-0000-0000-000000000000/11111111-1111-1111-1111-111111111111`). +Promotion requires an exact match for the verified identity and an explicit tenant +match in `AUTH_BOOTSTRAP_TENANTS`. It is **promote-only**: an existing admin is never +demoted, and users not listed here are never auto-promoted. + +Bootstrap does not depend on `email` or `email_verified`. Ordinary Entra workforce +and seeded entra-local identities can therefore bootstrap without custom email +claims when both allowlists match. Token verification remains required; this is +not a local authentication bypass. Email storage is unchanged: only an explicitly +verified profile email is persisted. + +### AUTH_BOOTSTRAP_TENANTS +**Default:** (empty) +**Type:** comma-separated list of tenant IDs + +Tenant allowlist that gates admin bootstrap. This setting is required when +`AUTH_BOOTSTRAP_ADMINS` is non-empty; otherwise the API fails startup. An +identity is promoted only when its tenant is explicitly listed here. + +> **Future — Graph profile enrichment.** `email`/`displayName` are read directly +> from the verified token claims during explicit login today (no Microsoft Graph call, no client +> secret). A later On-Behalf-Of enrichment would introduce +> `AUTH_API_CLIENT_SECRET`; it is **not** used now. + ## Token Manager Configuration ### TOKEN_MANAGER_URL diff --git a/NOTICE b/NOTICE index b54575864..d93c35975 100644 --- a/NOTICE +++ b/NOTICE @@ -10816,6 +10816,36 @@ SOFTWARE. ----------- +The following npm package may be included in this product: + + - jose@5.10.0 + +This package contains the following license: + +The MIT License (MIT) + +Copyright (c) 2018 Filip Skokan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +----------- + The following npm package may be included in this product: - cross-spawn@7.0.6 diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap index 3dd189089..3d347ffca 100644 --- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap +++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap @@ -3549,6 +3549,32 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "type": "object", }, + "UserMeResponse": { + "properties": { + "displayName": { + "type": "string", + }, + "email": { + "type": "string", + }, + "id": { + "type": "string", + }, + "idp": { + "type": "string", + }, + "idpTenant": { + "type": "string", + }, + "role": { + "type": "string", + }, + }, + "required": [ + "id", + ], + "type": "object", + }, "ValidateKeyInput": { "properties": { "token": { @@ -3561,6 +3587,14 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "type": "object", }, }, + "securitySchemes": { + "bearerAuth": { + "bearerFormat": "JWT", + "description": "Use the unchanged IdP access token, without the Bearer prefix.", + "scheme": "bearer", + "type": "http", + }, + }, }, "info": { "description": "REST API for the Scope platform — benchmarking AI coding agents", @@ -11044,6 +11078,74 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, + "/api/v1/users/me": { + "get": { + "description": "Read the existing authenticated Scope identity without enrollment or profile writes. Responses must not be HTTP-cached.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserMeResponse", + }, + }, + }, + "description": "Success", + }, + "401": { + "description": "Not authenticated", + }, + "403": { + "description": "User is not enrolled or is disabled", + }, + "503": { + "description": "Authentication service unavailable", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Get the authenticated user's identity", + "tags": [ + "Users", + ], + }, + "post": { + "description": "JIT-enroll the authenticated user, refresh their profile and lastLoginAt, apply bootstrap-admin rules, and warm the access cache. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserMeResponse", + }, + }, + }, + "description": "Success", + }, + "401": { + "description": "Not authenticated", + }, + "403": { + "description": "User is disabled", + }, + "503": { + "description": "Authentication service unavailable", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Enroll the authenticated user", + "tags": [ + "Users", + ], + }, + }, "/api/v1/version": { "get": { "responses": { diff --git a/apps/api/src/auth/auth-flow.integration.test.ts b/apps/api/src/auth/auth-flow.integration.test.ts new file mode 100644 index 000000000..98cd01516 --- /dev/null +++ b/apps/api/src/auth/auth-flow.integration.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { MongoClient, type Collection } from "mongodb"; +import { Redis } from "ioredis"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { EntraIdAuthProvider, type UserDocument } from "shared"; +import { app, _injectTestDependencies } from "../index.js"; +import { createAllMockDependencies } from "../test-helpers.js"; +import { UserStore } from "./user-store.js"; +import { RedisUserAccessCache } from "./user-access-cache.js"; +import { UserAccessResolver } from "./user-access-resolver.js"; + +vi.mock("db-migrations/check-migrations", () => ({ + checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: [], pending: [] }), +})); +vi.mock("../llm.js", () => ({ + isLlmAvailable: vi.fn().mockReturnValue(false), generateCriteriaPrompt: vi.fn(), +})); +vi.mock("../prompt-feature-llm.js", () => ({ + isLlmAvailable: vi.fn().mockReturnValue(false), + generatePromptFeaturePrompt: vi.fn(), extractPromptFeatures: vi.fn(), +})); +vi.mock("../task-prompt-llm.js", () => ({ + isTaskPromptLlmAvailable: vi.fn().mockReturnValue(false), generateTaskPrompt: vi.fn(), +})); + +// Opt into isolated infrastructure; never use or clear the application's database. +const mongoUri = process.env.AUTH_TEST_MONGO_URI; +const redisPort = Number(process.env.AUTH_TEST_REDIS_PORT); +const tenant = "11111111-1111-1111-1111-111111111111"; +const subject = "aaaaaaaa-0000-0000-0000-000000000001"; + +describe.runIf(Boolean(mongoUri && redisPort))("IdP -> explicit login -> Redis/Mongo access", () => { + const database = `auth-test-${randomUUID()}`; + const keys = generateKeyPairSync("rsa", { modulusLength: 2048 }); + let mongo: MongoClient; + let users: Collection; + let inspector: Redis; + let cache: RedisUserAccessCache; + let store: UserStore; + let resolver: UserAccessResolver; + + function token(oid = subject, expired = false): string { + const now = Math.floor(Date.now() / 1000); + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ + iss: `https://login.microsoftonline.com/${tenant}/v2.0`, + aud: "scope-api", + tid: tenant, + oid, + exp: expired ? now - 10 : now + 300, + name: "Integration User", + email: "integration@example.test", + email_verified: true, + })).toString("base64url"); + const input = `${header}.${payload}`; + return `${input}.${sign("RSA-SHA256", Buffer.from(input), keys.privateKey).toString("base64url")}`; + } + + beforeAll(async () => { + if (!mongoUri) throw new Error("AUTH_TEST_MONGO_URI is required"); + mongo = new MongoClient(mongoUri); + await mongo.connect(); + users = mongo.db(database).collection("users"); + await users.createIndex({ idp: 1, idpTenant: 1, idpSubject: 1 }, { unique: true, name: "uniq_identity" }); + inspector = new Redis({ host: "127.0.0.1", port: redisPort, maxRetriesPerRequest: 1 }); + await inspector.ping(); + cache = new RedisUserAccessCache({ + redisHost: "127.0.0.1", redisPort, redisPassword: "", + }, { ttlSeconds: 60, namespace: database }); + store = new UserStore(users, { + bootstrapAdmins: new Set([`entra:${tenant}/${subject}`]), + bootstrapTenants: new Set([tenant]), + }); + resolver = new UserAccessResolver({ userStore: store, cache, enricher: null }); + _injectTestDependencies(createAllMockDependencies()); + _injectTestDependencies({ + authProvider: new EntraIdAuthProvider({ + authority: "https://login.microsoftonline.com/common", + audience: "scope-api", + jwks: { + resolve: async () => keys.publicKey, + getCurrentJwks: () => ({ + keys: [{ + kty: "RSA", + issuer: "https://login.microsoftonline.com/{tenantid}/v2.0", + }], + }), + }, + }), + userAccessResolver: resolver, + }); + }); + + afterAll(async () => { + _injectTestDependencies({ authProvider: null, userAccessResolver: null }); + await cache?.close(); + if (inspector) { + const ownKeys = await inspector.keys(`auth-user:v1:${encodeURIComponent(database)}:*`); + if (ownKeys.length) await inspector.del(...ownKeys); + await inspector.quit(); + } + if (mongo) { + await mongo.db(database).dropDatabase(); + await mongo.close(); + } + }); + + it("enrolls only on login, caches role, and performs read-only lookup after expiry", async () => { + const bearer = `Bearer ${token()}`; + const notEnrolled = await request(app).get("/api/v1/feature-flags").set("Authorization", bearer); + expect(notEnrolled.status).toBe(403); + expect(notEnrolled.body.code).toBe("user_not_enrolled"); + expect(await users.countDocuments()).toBe(0); + + const login = await request(app).post("/api/v1/users/me").set("Authorization", bearer); + expect(login.status).toBe(200); + expect(login.body.role).toBe("admin"); + expect(login.body.id).not.toBe(subject); + const stored = await users.findOne({ idpSubject: subject }); + if (!stored) throw new Error("Login did not persist the user"); + expect(login.body.id).toBe(stored._id); + const lastLogin = stored.lastLoginAt?.getTime(); + expect(lastLogin).toBeTypeOf("number"); + + const ownKeys = await inspector.keys(`auth-user:v1:${encodeURIComponent(database)}:*`); + expect(ownKeys).toHaveLength(1); + expect(ownKeys[0]).toContain(`:entra:${tenant}:${subject}`); + expect(await inspector.ttl(ownKeys[0])).toBeGreaterThan(0); + const lookup = vi.spyOn(store, "findByIdentity"); + const upsert = vi.spyOn(store, "upsertOnLogin"); + const read = vi.spyOn(cache, "get"); + const warm = vi.spyOn(cache, "set"); + + const me = await request(app).get("/api/v1/users/me").set("Authorization", bearer); + expect(me.status).toBe(200); + expect(me.body.id).toBe(stored._id); + expect(lookup).not.toHaveBeenCalled(); + expect(upsert).not.toHaveBeenCalled(); + expect(warm).not.toHaveBeenCalled(); + + read.mockClear(); + const expired = await request(app).get("/api/v1/users/me").set("Authorization", `Bearer ${token(subject, true)}`); + expect(expired.status).toBe(401); + expect(read).not.toHaveBeenCalled(); + + await users.updateOne({ idpSubject: subject }, { $set: { role: "user" } }); + await inspector.pexpire(ownKeys[0], 100); + const beforeExpiry = await request(app).get("/api/v1/users/me").set("Authorization", bearer); + expect(beforeExpiry.body.role).toBe("admin"); + expect(await inspector.pttl(ownKeys[0])).toBeLessThanOrEqual(100); + await delay(120); + const afterExpiry = await request(app).get("/api/v1/users/me").set("Authorization", bearer); + expect(afterExpiry.body.role).toBe("user"); + expect(lookup).toHaveBeenCalledOnce(); + expect(warm).toHaveBeenCalledOnce(); + expect(upsert).not.toHaveBeenCalled(); + expect((await users.findOne({ idpSubject: subject }))?.lastLoginAt?.getTime()).toBe(lastLogin); + + await users.updateOne({ idpSubject: subject }, { $set: { disabledAt: new Date() } }); + await inspector.del(ownKeys[0]); + const disabled = await request(app).get("/api/v1/users/me").set("Authorization", bearer); + expect(disabled.status).toBe(403); + expect(disabled.body.code).toBe("user_disabled"); + expect(await inspector.exists(ownKeys[0])).toBe(0); + expect(upsert).not.toHaveBeenCalled(); + vi.restoreAllMocks(); + }); + + it("uses MongoDB and admits no missing users when the cache is unavailable", async () => { + const otherSubject = randomUUID(); + const bearer = `Bearer ${token(otherSubject)}`; + const login = await request(app).post("/api/v1/users/me").set("Authorization", bearer); + expect(login.status).toBe(200); + await cache.close(); + const before = await users.findOne({ idpSubject: otherSubject }); + const res = await request(app).get("/api/v1/users/me").set("Authorization", bearer); + expect(res.status).toBe(200); + expect(res.body.role).toBe("user"); + const missing = await request(app).get("/api/v1/users/me").set("Authorization", `Bearer ${token("missing-user")}`); + expect(missing.status).toBe(403); + expect(missing.body.code).toBe("user_not_enrolled"); + expect((await users.findOne({ idpSubject: otherSubject }))?.lastLoginAt).toEqual(before?.lastLoginAt); + expect(await users.countDocuments({ idpSubject: otherSubject })).toBe(1); + }); +}); diff --git a/apps/api/src/auth/error-handler.ts b/apps/api/src/auth/error-handler.ts new file mode 100644 index 000000000..1f937ec72 --- /dev/null +++ b/apps/api/src/auth/error-handler.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ErrorRequestHandler } from "express"; +import { AuthError } from "shared"; +import { UserAccessError } from "./user-access-resolver.js"; + +/** Keep login and normal-request authentication failures on the same HTTP contract. */ +export const authErrorHandler: ErrorRequestHandler = ( + err: unknown, _req, res, next, +) => { + if (err instanceof AuthError) { + const unavailable = err.code === "service_unavailable"; + res.status(unavailable ? 503 : 401).json({ + error: unavailable ? "Authentication service unavailable" : "Invalid or expired token", + code: err.code, + }); + return; + } + if (err instanceof UserAccessError) { + res.status(err.status).json({ error: err.message, code: err.code }); + return; + } + next(err); +}; diff --git a/apps/api/src/auth/middleware.test.ts b/apps/api/src/auth/middleware.test.ts new file mode 100644 index 000000000..e330a8f70 --- /dev/null +++ b/apps/api/src/auth/middleware.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import { AuthError, type AuthProvider, type VerifiedIdentity } from "shared"; +import { createAuthMiddleware, createUserAccessMiddleware } from "./middleware.js"; +import { authErrorHandler } from "./error-handler.js"; +import { getUser } from "./types.js"; +import { UserAccessError, type UserAccessService } from "./user-access-resolver.js"; + +const identity: VerifiedIdentity = { + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", +}; +const principal = { + id: "scope-user", + isAuthenticated: true, + role: "user", + ...identity, +}; + +function setup(providerEnabled = true, resolverEnabled = true) { + const provider = { + id: "entra", + verifyAccessToken: vi.fn(async () => identity), + } satisfies AuthProvider; + const resolver = { + resolveExisting: vi.fn(async () => principal), + enrollOnLogin: vi.fn(async () => principal), + } satisfies UserAccessService; + const app = express(); + app.use(createAuthMiddleware({ getProvider: () => providerEnabled ? provider : null })); + app.get("/verified-only", (req, res) => { + res.json({ identity: req.auth?.identity, user: req.user }); + }); + app.use(createUserAccessMiddleware(() => resolverEnabled ? resolver : null)); + app.get(["/api/v1/private", "/health", "/ready", "/api-docs/test"], (req, res) => { + res.json(getUser(req)); + }); + app.use(authErrorHandler); + return { app, provider, resolver }; +} + +describe("IdP verification and application access middleware", () => { + it.each(["/health", "/ready", "/api-docs/test"])("skips public path %s", async (path) => { + const { app, provider, resolver } = setup(); + expect((await request(app).get(path).set("Authorization", "Bearer bad")).status).toBe(200); + expect(provider.verifyAccessToken).not.toHaveBeenCalled(); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + }); + + it("preserves anonymous callers without a token", async () => { + const { app, provider, resolver } = setup(); + const res = await request(app).get("/api/v1/private"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: "anonymous", isAuthenticated: false }); + expect(provider.verifyAccessToken).not.toHaveBeenCalled(); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }); + + it("preserves anonymous mode without an IdP provider", async () => { + const { app, resolver } = setup(false); + const res = await request(app).get("/api/v1/private").set("Authorization", "Bearer token"); + expect(res.body.id).toBe("anonymous"); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + }); + + it.each(["bearer token", "bEaReR token", "bearer token"])( + "makes verified identity available before access resolution for %s", async (header) => { + const { app, provider, resolver } = setup(); + const res = await request(app).get("/verified-only").set("Authorization", header); + expect(res.status).toBe(200); + expect(res.body).toEqual({ identity }); + expect(provider.verifyAccessToken).toHaveBeenCalledWith("token"); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }, + ); + + it("verifies before resolving and never enrolls from a normal route", async () => { + const { app, provider, resolver } = setup(); + const res = await request(app).get("/api/v1/private?login=true").set("Authorization", "Bearer token"); + expect(res.status).toBe(200); + expect(res.body).toEqual(principal); + expect(resolver.resolveExisting).toHaveBeenCalledExactlyOnceWith(identity); + expect(provider.verifyAccessToken.mock.invocationCallOrder[0]).toBeLessThan( + resolver.resolveExisting.mock.invocationCallOrder[0], + ); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }); + + it.each(["invalid_token", "expired_token", "invalid_audience"] as const)( + "rejects %s before any access lookup", async (code) => { + const { app, provider, resolver } = setup(); + provider.verifyAccessToken.mockRejectedValue(new AuthError(code, "bad token")); + const res = await request(app).get("/api/v1/private").set("Authorization", "Bearer token"); + expect(res.status).toBe(401); + expect(res.body.code).toBe(code); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + }, + ); + + it.each(["Basic value", "Bearer", "Bearer token extra"])( + "rejects malformed credentials %s instead of downgrading to anonymous", async (header) => { + const { app, provider, resolver } = setup(); + const res = await request(app).get("/api/v1/private").set("Authorization", header); + expect(res.status).toBe(401); + expect(res.body.code).toBe("invalid_token"); + expect(provider.verifyAccessToken).not.toHaveBeenCalled(); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }, + ); + + it.each(["", " ", "\t", "Bearer ", "Bearer "])( + "rejects empty credentials %j instead of downgrading to anonymous", async (header) => { + const { app, provider, resolver } = setup(); + const res = await request(app).get("/api/v1/private").set("Authorization", header); + expect(res.status).toBe(401); + expect(res.body.code).toBe("invalid_token"); + expect(provider.verifyAccessToken).not.toHaveBeenCalled(); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }, + ); + + it("returns 503 for unavailable JWKS before looking up access", async () => { + const { app, provider, resolver } = setup(); + provider.verifyAccessToken.mockRejectedValue(new AuthError("service_unavailable", "JWKS unavailable")); + const res = await request(app).get("/api/v1/private").set("Authorization", "Bearer token"); + expect(res.status).toBe(503); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + }); + + it("returns 503 if the access resolver has not initialized", async () => { + const { app, provider } = setup(true, false); + const res = await request(app).get("/api/v1/private").set("Authorization", "Bearer token"); + expect(res.status).toBe(503); + expect(provider.verifyAccessToken).toHaveBeenCalledOnce(); + }); + + it.each([ + ["user_not_enrolled", 403], + ["user_disabled", 403], + ["invalid_principal", 401], + ["service_unavailable", 503], + ] as const)("maps resolver failure %s to %s", async (code, status) => { + const { app, resolver } = setup(); + resolver.resolveExisting.mockRejectedValue(new UserAccessError(code)); + const res = await request(app).get("/api/v1/private").set("Authorization", "Bearer token"); + expect(res.status).toBe(status); + expect(res.body.code).toBe(code); + }); +}); diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts new file mode 100644 index 000000000..d1a753c49 --- /dev/null +++ b/apps/api/src/auth/middleware.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Request, RequestHandler } from "express"; +import { AuthError, type AuthProvider } from "shared"; +import { ANONYMOUS_USER } from "./types.js"; +import type { UserAccessService } from "./user-access-resolver.js"; + +const PUBLIC_PATHS: ReadonlySet = new Set([ + "/health", + "/ready", + "/about", + "/api/v1/version", + "/openapi.json", +]); + +function isPublicPath(path: string): boolean { + return PUBLIC_PATHS.has(path) || + path === "/api-docs" || path.startsWith("/api-docs/"); +} + +function extractBearerToken(req: Request): string | null { + const header = req.headers.authorization; + if (header === undefined) return null; + const match = /^Bearer\s+(\S+)$/i.exec(header.trim()); + if (!match) throw new AuthError("invalid_token", "Malformed bearer header"); + return match[1]; +} + +export interface AuthMiddlewareDeps { + getProvider: () => AuthProvider | null; +} + +/** Verify credentials before either login enrollment or cached access resolution. */ +export function createAuthMiddleware(deps: AuthMiddlewareDeps): RequestHandler { + return async (req, _res, next): Promise => { + try { + if (isPublicPath(req.path)) { + next(); + return; + } + const provider = deps.getProvider(); + if (!provider) { + req.user = ANONYMOUS_USER; + next(); + return; + } + const token = extractBearerToken(req); + if (!token) { + req.user = ANONYMOUS_USER; + next(); + return; + } + const identity = await provider.verifyAccessToken(token); + req.auth = { identity, token }; + next(); + } catch (err) { + next(err); + } + }; +} + +/** Mounted after /users/me: normal requests may resolve, but never enroll, a user. */ +export function createUserAccessMiddleware( + getResolver: () => UserAccessService | null, +): RequestHandler { + return async (req, _res, next): Promise => { + try { + if (!req.auth) { + next(); + return; + } + const resolver = getResolver(); + if (!resolver) { + throw new AuthError("service_unavailable", "Authentication service unavailable"); + } + req.user = await resolver.resolveExisting(req.auth.identity); + next(); + } catch (err) { + next(err); + } + }; +} diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts new file mode 100644 index 000000000..16d570499 --- /dev/null +++ b/apps/api/src/auth/types.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Request } from "express"; +import { ANONYMOUS_USER_ID, type VerifiedIdentity } from "shared"; + +/** Verified IdP credentials, before resolving application access. Request-local only. */ +export interface VerifiedAuthContext { + identity: VerifiedIdentity; + token: string; +} + +/** + * The principal set on `req.user` for every request. + * + * This is identity only. Authorization (roles/permissions enforcement) is NOT + * part of this milestone — `role` is carried as advisory metadata, never used + * to allow or deny a request here. + */ +export interface AuthenticatedUser { + /** Scope User ID (UUID) for authenticated users, or "anonymous". */ + id: string; + /** Whether a valid token was presented and resolved to a user. */ + isAuthenticated: boolean; + /** Loose role string (advisory only). */ + role?: string; + email?: string; + displayName?: string; + idp?: string; + idpTenant?: string; + idpSubject?: string; + /** Reserved for future service-to-service principals. Always false today. */ + isService?: boolean; +} + +// Augment Express's Request so handlers can read `req.user`. +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + auth?: VerifiedAuthContext; + user?: AuthenticatedUser; + } + } +} + +/** Shared, immutable anonymous principal. */ +export const ANONYMOUS_USER: AuthenticatedUser = Object.freeze({ + id: ANONYMOUS_USER_ID, + isAuthenticated: false, +}); + +/** Read the principal for a request, defaulting to anonymous. */ +export function getUser(req: Request): AuthenticatedUser { + return req.user ?? ANONYMOUS_USER; +} diff --git a/apps/api/src/auth/user-access-cache.test.ts b/apps/api/src/auth/user-access-cache.test.ts new file mode 100644 index 000000000..983852e4b --- /dev/null +++ b/apps/api/src/auth/user-access-cache.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { RedisOptions } from "ioredis"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RedisConfig, VerifiedIdentity } from "shared"; +import type { AuthenticatedUser } from "./types.js"; +import { RedisUserAccessCache, userAccessCacheKey } from "./user-access-cache.js"; + +const { redis, RedisMock } = vi.hoisted(() => { + const redis = { + status: "ready", + get: vi.fn<(key: string) => Promise>(), + set: vi.fn<(key: string, value: string, expiry: "EX", ttl: number) => Promise<"OK">>(), + del: vi.fn<(key: string) => Promise>(), + disconnect: vi.fn<() => void>(), + on: vi.fn<(event: string, handler: () => void) => void>(), + }; + return { + redis, + RedisMock: vi.fn(function (_options: RedisOptions) { return redis; }), + }; +}); + +vi.mock("ioredis", () => ({ Redis: RedisMock })); + +const IDENTITY: VerifiedIdentity = { + idp: "entra", idpTenant: "tenant-1", idpSubject: "subject-1", + email: "claim@example.com", +}; +const USER: AuthenticatedUser = { + id: "scope-uuid-1", role: "admin", isAuthenticated: true, isService: false, + idp: IDENTITY.idp, idpTenant: IDENTITY.idpTenant, idpSubject: IDENTITY.idpSubject, + email: "verified@example.com", displayName: "Stored Name", +}; +const SNAPSHOT = { + version: 1, id: USER.id, role: USER.role, + idp: USER.idp, idpTenant: USER.idpTenant, idpSubject: USER.idpSubject, + email: USER.email, displayName: USER.displayName, +}; +const CONFIG: RedisConfig = { redisHost: "redis", redisPort: 6379, redisPassword: "" }; +const OPTIONS = { ttlSeconds: 300, namespace: "scope-db" }; +const KEY = "auth-user:v1:scope-db:entra:tenant-1:subject-1"; +const networkError = () => Object.assign(new Error("sensitive connection details"), { code: "ECONNRESET" }); + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("REDIS_TLS", ""); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "info").mockImplementation(() => {}); + redis.status = "ready"; + redis.get.mockResolvedValue(null); + redis.set.mockResolvedValue("OK"); + redis.del.mockResolvedValue(1); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("RedisUserAccessCache", () => { + it("encodes every namespace/provider/tenant/subject component independently", () => { + const identity = { ...IDENTITY, idp: "idp:one", idpTenant: "tid/\u00e9", idpSubject: "oid:%" }; + expect(userAccessCacheKey("db:one/two", identity)) + .toBe("auth-user:v1:db%3Aone%2Ftwo:idp%3Aone:tid%2F%C3%A9:oid%3A%25"); + expect(userAccessCacheKey("a:b", IDENTITY)) + .not.toBe(userAccessCacheKey("a", { ...IDENTITY, idp: "b:entra" })); + expect(userAccessCacheKey("one", IDENTITY)).not.toBe(userAccessCacheKey("two", IDENTITY)); + expect(userAccessCacheKey("one", { ...IDENTITY, email: "different@example.com" })) + .toBe(userAccessCacheKey("one", IDENTITY)); + }); + + it("bounds connection/command waits and does not queue or replay writes on reconnect", () => { + new RedisUserAccessCache(CONFIG, OPTIONS); + const options = RedisMock.mock.calls[0][0]; + expect(options).toMatchObject({ + host: "redis", port: 6379, connectTimeout: 1_000, commandTimeout: 500, + socketTimeout: 1_000, enableOfflineQueue: false, + autoResendUnfulfilledCommands: false, maxRetriesPerRequest: 0, + }); + expect(options.retryStrategy?.(1)).toBe(250); + expect(options.retryStrategy?.(100)).toBe(5_000); + expect(options.reconnectOnError?.(new Error("READONLY replica"))).toBe(true); + expect(options.reconnectOnError?.(new Error("other error"))).toBe(false); + }); + + it("uses certificate validation with explicit TLS and honors an explicit TLS disable", () => { + vi.stubEnv("REDIS_TLS", "true"); + new RedisUserAccessCache(CONFIG, OPTIONS); + expect(RedisMock.mock.calls[0][0].tls).toEqual({}); + vi.stubEnv("REDIS_TLS", "false"); + new RedisUserAccessCache({ ...CONFIG, redisHost: "remote", redisPassword: "secret" }, OPTIONS); + expect(RedisMock.mock.calls[1][0].tls).toBeUndefined(); + vi.stubEnv("REDIS_TLS", undefined); + new RedisUserAccessCache({ ...CONFIG, redisHost: "remote", redisPassword: "secret" }, OPTIONS); + expect(RedisMock.mock.calls[2][0].tls).toEqual({}); + }); + + it.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1])("rejects invalid TTL %s", (ttlSeconds) => { + expect(() => new RedisUserAccessCache(CONFIG, { ...OPTIONS, ttlSeconds })) + .toThrow(/positive safe integer/); + expect(RedisMock).not.toHaveBeenCalled(); + }); + + it("requires a non-empty deployment/database namespace", () => { + expect(() => new RedisUserAccessCache(CONFIG, { ...OPTIONS, namespace: " " })) + .toThrow(/namespace/); + expect(RedisMock).not.toHaveBeenCalled(); + }); + + it("reports unavailable without creating Redis when the host is absent", async () => { + const cache = new RedisUserAccessCache({ ...CONFIG, redisHost: "" }, OPTIONS); + expect(await cache.get(IDENTITY)).toEqual({ status: "unavailable" }); + expect(await cache.set(IDENTITY, USER)).toEqual({ status: "unavailable" }); + expect(await cache.delete(IDENTITY)).toEqual({ status: "unavailable" }); + await cache.close(); + expect(RedisMock).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledOnce(); + }); + + it("returns an explicit miss when Redis has no entry", async () => { + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + expect(await cache.get(IDENTITY)).toEqual({ status: "miss" }); + expect(redis.get).toHaveBeenCalledExactlyOnceWith(KEY); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("returns the active stored principal without extending TTL", async () => { + redis.get.mockResolvedValue(JSON.stringify(SNAPSHOT)); + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + expect(await cache.get(IDENTITY)).toEqual({ status: "hit", user: USER }); + expect(await cache.get(IDENTITY)).toEqual({ status: "hit", user: USER }); + expect(redis.set).not.toHaveBeenCalled(); + expect(redis.del).not.toHaveBeenCalled(); + }); + + it("writes only a versioned minimal snapshot in one atomic SET EX, never tokens", async () => { + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + const userWithExtras = { ...USER, rawToken: "secret bearer", permissionsAdd: ["all"] }; + expect(await cache.set(IDENTITY, userWithExtras)).toEqual({ status: "ok" }); + expect(redis.set).toHaveBeenCalledExactlyOnceWith(KEY, JSON.stringify(SNAPSHOT), "EX", 300); + expect(redis.set.mock.calls[0][1]).not.toContain("secret"); + }); + + it("expires at the original fixed TTL despite repeated cache hits", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const entries = new Map(); + redis.set.mockImplementation(async (key, payload, _ex, ttl) => { + entries.set(key, { payload, expiresAt: Date.now() + ttl * 1000 }); + return "OK"; + }); + redis.get.mockImplementation(async (key) => { + const entry = entries.get(key); + return entry && entry.expiresAt > Date.now() ? entry.payload : null; + }); + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + await cache.set(IDENTITY, USER); + await vi.advanceTimersByTimeAsync(299_999); + expect((await cache.get(IDENTITY)).status).toBe("hit"); + await vi.advanceTimersByTimeAsync(1); + expect((await cache.get(IDENTITY)).status).toBe("miss"); + expect(redis.set).toHaveBeenCalledOnce(); + }); + + it.each([ + ["broken JSON", "private malformed contents"], + ["null", "null"], + ["array", "[]"], + ["unsupported version", JSON.stringify({ ...SNAPSHOT, version: 2 })], + ["missing role", JSON.stringify({ ...SNAPSHOT, role: undefined })], + ["invalid role", JSON.stringify({ ...SNAPSHOT, role: [] })], + ["empty ID", JSON.stringify({ ...SNAPSHOT, id: "" })], + ["system principal", JSON.stringify({ ...SNAPSHOT, id: "system" })], + ["anonymous principal", JSON.stringify({ ...SNAPSHOT, id: "anonymous" })], + ["other provider", JSON.stringify({ ...SNAPSHOT, idp: "other" })], + ["other tenant", JSON.stringify({ ...SNAPSHOT, idpTenant: "other" })], + ["other subject", JSON.stringify({ ...SNAPSHOT, idpSubject: "other" })], + ["disabled flag", JSON.stringify({ ...SNAPSHOT, disabledAt: "2026-09-14" })], + ["bearer field", JSON.stringify({ ...SNAPSHOT, rawToken: "secret bearer" })], + ["invalid profile", JSON.stringify({ ...SNAPSHOT, displayName: 3 })], + ])("logs, evicts and misses on %s", async (_label, value) => { + redis.get.mockResolvedValue(value); + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + expect(await cache.get(IDENTITY)).toEqual({ status: "miss" }); + expect(redis.del).toHaveBeenCalledExactlyOnceWith(KEY); + expect(redis.set).not.toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("Invalid active-user snapshot")); + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(value); + }); + + it.each([ + { ...USER, isAuthenticated: false }, + { ...USER, isService: true }, + { ...USER, id: "system" }, + { ...USER, id: "anonymous" }, + { ...USER, idpTenant: "other" }, + { ...USER, role: undefined }, + ])("rejects invalid write principals without a Redis command", async (user) => { + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + await expect(cache.set(IDENTITY, user)).rejects.toThrow(TypeError); + expect(redis.set).not.toHaveBeenCalled(); + }); + + it.each([ + networkError(), + Object.assign(new Error("redis server failure"), { name: "ReplyError" }), + Object.assign(new Error("retry limit"), { name: "MaxRetriesPerRequestError" }), + new Error("Command timed out"), + new Error("Socket timeout. Expecting data, but didn't receive any in 1000ms."), + new Error("Connection is closed."), + new Error("Stream isn't writeable and enableOfflineQueue options is false"), + ])("reports and logs expected Redis read failures ($message)", async (error) => { + redis.get.mockRejectedValue(error); + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + expect(await cache.get(IDENTITY)).toEqual({ status: "unavailable" }); + expect(console.warn).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("Redis unavailable")); + expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain(error.message); + }); + + it("reports write/delete outages without granting or hiding database access decisions", async () => { + redis.set.mockRejectedValue(networkError()); + redis.del.mockRejectedValue(networkError()); + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + expect(await cache.set(IDENTITY, USER)).toEqual({ status: "unavailable" }); + expect(await cache.delete(IDENTITY)).toEqual({ status: "unavailable" }); + expect(console.warn).toHaveBeenCalledOnce(); + }); + + it("still treats corruption as a miss if best-effort eviction is unavailable", async () => { + redis.get.mockResolvedValue("malformed"); + redis.del.mockRejectedValue(networkError()); + expect(await new RedisUserAccessCache(CONFIG, OPTIONS).get(IDENTITY)).toEqual({ status: "miss" }); + expect(console.warn).toHaveBeenCalledTimes(2); + }); + + it("does not wait for Redis to connect or reconnect and recovers once ready", async () => { + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + for (const status of ["connecting", "reconnecting", "end"]) { + redis.status = status; + expect(await cache.get(IDENTITY)).toEqual({ status: "unavailable" }); + expect(await cache.set(IDENTITY, USER)).toEqual({ status: "unavailable" }); + } + expect(redis.get).not.toHaveBeenCalled(); + expect(redis.set).not.toHaveBeenCalled(); + redis.status = "ready"; + redis.on.mock.calls.find(([event]) => event === "ready")?.[1](); + expect(await cache.get(IDENTITY)).toEqual({ status: "miss" }); + expect(console.info).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("Redis available again")); + }); + + it("rate-limits error events, failed requests, corrupt entries and recovery logs", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + redis.get.mockRejectedValue(networkError()); + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + const errorEvent = redis.on.mock.calls.find(([event]) => event === "error")?.[1]; + errorEvent?.(); + await cache.get(IDENTITY); + await cache.get(IDENTITY); + expect(console.warn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(60_000); + await cache.get(IDENTITY); + expect(console.warn).toHaveBeenCalledTimes(2); + redis.get.mockResolvedValue("invalid"); + await cache.get(IDENTITY); + await cache.get(IDENTITY); + expect(console.warn).toHaveBeenCalledTimes(3); + expect(console.info).toHaveBeenCalledOnce(); + errorEvent?.(); + await cache.get(IDENTITY); + expect(console.info).toHaveBeenCalledOnce(); + }); + + it("propagates unexpected implementation failures rather than hiding them as Redis outages", async () => { + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + const error = new TypeError("programmer bug"); + redis.get.mockRejectedValue(error); + redis.set.mockRejectedValue(error); + redis.del.mockRejectedValue(error); + await expect(cache.get(IDENTITY)).rejects.toBe(error); + await expect(cache.set(IDENTITY, USER)).rejects.toBe(error); + await expect(cache.delete(IDENTITY)).rejects.toBe(error); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("closes immediately and idempotently without a network-dependent QUIT", async () => { + const cache = new RedisUserAccessCache(CONFIG, OPTIONS); + await cache.close(); + await cache.close(); + expect(redis.disconnect).toHaveBeenCalledOnce(); + expect(await cache.get(IDENTITY)).toEqual({ status: "unavailable" }); + expect(redis.get).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/auth/user-access-cache.ts b/apps/api/src/auth/user-access-cache.ts new file mode 100644 index 000000000..a9474123e --- /dev/null +++ b/apps/api/src/auth/user-access-cache.ts @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Redis } from "ioredis"; +import { z } from "zod"; +import { + ANONYMOUS_USER_ID, + SYSTEM_USER_ID, + type RedisConfig, + type VerifiedIdentity, +} from "shared"; +import type { AuthenticatedUser } from "./types.js"; + +export type UserAccessCacheResult = + | { status: "hit"; user: AuthenticatedUser } + | { status: "miss" } + | { status: "unavailable" }; + +export type UserAccessCacheWriteResult = { status: "ok" | "unavailable" }; + +/** Expected backend failures are logged by the adapter, not thrown or treated as misses. */ +export interface UserAccessCache { + get(identity: VerifiedIdentity): Promise; + set(identity: VerifiedIdentity, user: AuthenticatedUser): Promise; + delete(identity: VerifiedIdentity): Promise; + close(): Promise; +} + +export interface RedisUserAccessCacheOptions { + ttlSeconds: number; + /** Scope MongoDB database name; isolates independent deployments sharing Redis. */ + namespace: string; +} + +const SnapshotSchema = z.object({ + version: z.literal(1), + id: z.string().min(1).refine((id) => id !== SYSTEM_USER_ID && id !== ANONYMOUS_USER_ID), + role: z.string().min(1), + idp: z.string().min(1), + idpTenant: z.string().min(1), + idpSubject: z.string().min(1), + email: z.string().optional(), + displayName: z.string().optional(), +}).strict(); + +type Snapshot = z.infer; +type RedisResult = { status: "ok"; value: T } | { status: "unavailable" }; + +const LOG_INTERVAL_MS = 60_000; +const NETWORK_ERROR_CODES = new Set([ + "ECONNREFUSED", "ECONNRESET", "ECONNABORTED", "EPIPE", "ETIMEDOUT", + "ENOTFOUND", "EAI_AGAIN", "ENETUNREACH", "EHOSTUNREACH", + "ERR_TLS_CERT_ALTNAME_INVALID", "CERT_HAS_EXPIRED", "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", +]); + +function isExpectedRedisError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code: unknown = "code" in error ? error.code : undefined; + return (typeof code === "string" && NETWORK_ERROR_CODES.has(code)) || + error.name === "ReplyError" || + error.name === "MaxRetriesPerRequestError" || + error.message === "Command timed out" || + /^Socket timeout\. Expecting data, but didn't receive any in [0-9]+ms\.$/.test(error.message) || + error.message === "Connection is closed." || + error.message === "Stream isn't writeable and enableOfflineQueue options is false"; +} + +function matchesIdentity(snapshot: Snapshot, identity: VerifiedIdentity): boolean { + return snapshot.idp === identity.idp && + snapshot.idpTenant === identity.idpTenant && + snapshot.idpSubject === identity.idpSubject; +} + +function snapshotFor(user: AuthenticatedUser): unknown { + return { + version: 1, + id: user.id, + role: user.role, + idp: user.idp, + idpTenant: user.idpTenant, + idpSubject: user.idpSubject, + ...(user.email !== undefined ? { email: user.email } : {}), + ...(user.displayName !== undefined ? { displayName: user.displayName } : {}), + }; +} + +/** The cache accepts only active human principals matching the verified identity. */ +export function isActiveUserAccess(identity: VerifiedIdentity, user: AuthenticatedUser): boolean { + if (user.isAuthenticated !== true || (user.isService !== undefined && user.isService !== false)) { + return false; + } + const parsed = SnapshotSchema.safeParse(snapshotFor(user)); + return parsed.success && matchesIdentity(parsed.data, identity); +} + +export function userAccessCacheKey(namespace: string, identity: VerifiedIdentity): string { + return `auth-user:v1:${[namespace, identity.idp, identity.idpTenant, identity.idpSubject] + .map((part) => encodeURIComponent(part)).join(":")}`; +} + +/** + * A blank Redis host creates no client: every operation reports unavailable and + * the resolver uses MongoDB. There is deliberately no process-local fallback cache. + */ +export class RedisUserAccessCache implements UserAccessCache { + private readonly redis: Redis | null; + private closed = false; + private unavailable = false; + private lastUnavailableLog = -Infinity; + private lastInvalidEntryLog = -Infinity; + private lastRecoveryLog = -Infinity; + + constructor( + config: RedisConfig, + private readonly options: RedisUserAccessCacheOptions, + ) { + if (!Number.isSafeInteger(options.ttlSeconds) || options.ttlSeconds <= 0) { + throw new Error("User access cache TTL must be a positive safe integer"); + } + if (!options.namespace.trim()) { + throw new Error("User access cache namespace must not be empty"); + } + if (!config.redisHost.trim()) { + this.redis = null; + this.warnUnavailable(); + return; + } + + const useTls = process.env.REDIS_TLS !== undefined + ? process.env.REDIS_TLS === "true" + : Boolean(config.redisPassword && + !["localhost", "127.0.0.1", "redis"].includes(config.redisHost)); + this.redis = new Redis({ + host: config.redisHost, + port: config.redisPort, + password: config.redisPassword || undefined, + ...(useTls ? { tls: {} } : {}), + connectTimeout: 1_000, + commandTimeout: 500, + socketTimeout: 1_000, + enableOfflineQueue: false, + autoResendUnfulfilledCommands: false, + maxRetriesPerRequest: 0, + retryStrategy: (attempt) => Math.min(attempt * 250, 5_000), + reconnectOnError: (error) => error.message.startsWith("READONLY "), + }); + this.redis.on("error", () => this.warnUnavailable()); + this.redis.on("ready", () => this.markAvailable()); + } + + async get(identity: VerifiedIdentity): Promise { + const key = userAccessCacheKey(this.options.namespace, identity); + const result = await this.run((redis) => redis.get(key)); + if (result.status === "unavailable") return result; + if (result.value === null) return { status: "miss" }; + + let payload: unknown; + try { + payload = JSON.parse(result.value); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + const parsed = SnapshotSchema.safeParse(payload); + if (!parsed.success || !matchesIdentity(parsed.data, identity)) { + this.warnInvalidEntry(); + await this.delete(identity); + return { status: "miss" }; + } + + const { version: _version, ...user } = parsed.data; + return { status: "hit", user: { ...user, isAuthenticated: true, isService: false } }; + } + + async set(identity: VerifiedIdentity, user: AuthenticatedUser): Promise { + if (!isActiveUserAccess(identity, user)) { + throw new TypeError("Cannot cache an invalid user access principal"); + } + const payload = JSON.stringify(snapshotFor(user)); + const key = userAccessCacheKey(this.options.namespace, identity); + const result = await this.run((redis) => + redis.set(key, payload, "EX", this.options.ttlSeconds)); + return { status: result.status }; + } + + async delete(identity: VerifiedIdentity): Promise { + const key = userAccessCacheKey(this.options.namespace, identity); + const result = await this.run((redis) => redis.del(key)); + return { status: result.status }; + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + // No network-dependent QUIT: also cancels pending background reconnection. + this.redis?.disconnect(); + } + + private async run(operation: (redis: Redis) => Promise): Promise> { + if (this.closed) return { status: "unavailable" }; + if (!this.redis || this.redis.status !== "ready") { + this.warnUnavailable(); + return { status: "unavailable" }; + } + try { + const value = await operation(this.redis); + this.markAvailable(); + return { status: "ok", value }; + } catch (error) { + if (!isExpectedRedisError(error)) throw error; + this.warnUnavailable(); + return { status: "unavailable" }; + } + } + + private warnUnavailable(): void { + this.unavailable = true; + const now = Date.now(); + if (now - this.lastUnavailableLog < LOG_INTERVAL_MS) return; + this.lastUnavailableLog = now; + console.warn("[user-access-cache] Redis unavailable; falling back to MongoDB"); + } + + private warnInvalidEntry(): void { + const now = Date.now(); + if (now - this.lastInvalidEntryLog < LOG_INTERVAL_MS) return; + this.lastInvalidEntryLog = now; + console.warn("[user-access-cache] Invalid active-user snapshot; evicting and falling back to MongoDB"); + } + + private markAvailable(): void { + if (!this.unavailable) return; + this.unavailable = false; + const now = Date.now(); + if (now - this.lastRecoveryLog < LOG_INTERVAL_MS) return; + this.lastRecoveryLog = now; + console.info("[user-access-cache] Redis available again"); + } +} diff --git a/apps/api/src/auth/user-access-resolver.test.ts b/apps/api/src/auth/user-access-resolver.test.ts new file mode 100644 index 000000000..002580ec5 --- /dev/null +++ b/apps/api/src/auth/user-access-resolver.test.ts @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + MongoNetworkError, + MongoNotConnectedError, + MongoOperationTimeoutError, + MongoServerError, + MongoServerSelectionError, +} from "mongodb"; +import { describe, expect, it, vi } from "vitest"; +import type { ProfileEnricher, UserDocument, VerifiedIdentity } from "shared"; +import type { AuthenticatedUser } from "./types.js"; +import type { UserAccessCache } from "./user-access-cache.js"; +import { + UserAccessError, + UserAccessResolver, + type UserAccessResolverOptions, + type UserAccessService, +} from "./user-access-resolver.js"; + +const IDENTITY: VerifiedIdentity = { + idp: "entra", idpTenant: "tenant-1", idpSubject: "subject-1", + email: "claim@example.com", displayName: "Token Name", emailVerified: true, +}; +const PRINCIPAL: AuthenticatedUser = { + id: "scope-uuid-1", role: "admin", isAuthenticated: true, isService: false, + idp: "entra", idpTenant: "tenant-1", idpSubject: "subject-1", + email: "verified@example.com", displayName: "Stored Name", +}; + +function document(overrides: Partial = {}): UserDocument { + return { + _id: PRINCIPAL.id, role: "admin", + idp: IDENTITY.idp, idpTenant: IDENTITY.idpTenant, idpSubject: IDENTITY.idpSubject, + email: PRINCIPAL.email, displayName: PRINCIPAL.displayName, + createdAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"), + lastLoginAt: new Date("2026-01-01"), ...overrides, + }; +} + +function setup() { + const userStore = { + findByIdentity: vi.fn() + .mockResolvedValue(document()), + upsertOnLogin: vi.fn() + .mockResolvedValue(document()), + }; + const cache = { + get: vi.fn().mockResolvedValue({ status: "miss" }), + set: vi.fn().mockResolvedValue({ status: "ok" }), + delete: vi.fn().mockResolvedValue({ status: "ok" }), + close: vi.fn().mockResolvedValue(undefined), + }; + const enricher = { + id: "test", + enrich: vi.fn().mockResolvedValue({ + email: "enriched@example.com", displayName: "Enriched Name", emailVerified: true, + }), + }; + const resolver: UserAccessService = new UserAccessResolver({ userStore, cache, enricher }); + return { resolver, userStore, cache, enricher }; +} + +describe("UserAccessResolver.resolveExisting", () => { + it("uses an active cache hit without a Mongo read, profile enrichment, upsert or TTL refresh", async () => { + const { resolver, userStore, cache, enricher } = setup(); + cache.get.mockResolvedValue({ status: "hit", user: PRINCIPAL }); + expect(await resolver.resolveExisting(IDENTITY)).toEqual(PRINCIPAL); + expect(cache.get).toHaveBeenCalledExactlyOnceWith(IDENTITY); + expect(userStore.findByIdentity).not.toHaveBeenCalled(); + expect(userStore.upsertOnLogin).not.toHaveBeenCalled(); + expect(enricher.enrich).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it.each(["miss", "unavailable"] as const)("reads Mongo and warms the cache on %s without login writes", async (status) => { + const { resolver, userStore, cache, enricher } = setup(); + cache.get.mockResolvedValue({ status }); + expect(await resolver.resolveExisting(IDENTITY)).toEqual(PRINCIPAL); + expect(userStore.findByIdentity).toHaveBeenCalledExactlyOnceWith(IDENTITY); + expect(cache.set).toHaveBeenCalledExactlyOnceWith(IDENTITY, PRINCIPAL); + expect(userStore.upsertOnLogin).not.toHaveBeenCalled(); + expect(enricher.enrich).not.toHaveBeenCalled(); + }); + + it("does not invent profile fields from current claims or persist mutable DB data", async () => { + const { resolver, userStore, cache } = setup(); + const stored = document({ email: undefined, displayName: undefined, permissionsAdd: ["unused"] }); + userStore.findByIdentity.mockResolvedValue(stored); + const user = await resolver.resolveExisting(IDENTITY); + expect(user).toEqual({ + id: PRINCIPAL.id, role: "admin", isAuthenticated: true, isService: false, + idp: "entra", idpTenant: "tenant-1", idpSubject: "subject-1", + }); + expect(user).not.toBe(stored); + expect(cache.set.mock.calls[0][1]).not.toHaveProperty("lastLoginAt"); + expect(cache.set.mock.calls[0][1]).not.toHaveProperty("permissionsAdd"); + }); + + it("keeps database access decisions authoritative when cache warming is unavailable", async () => { + const { resolver, cache } = setup(); + cache.set.mockResolvedValue({ status: "unavailable" }); + expect(await resolver.resolveExisting(IDENTITY)).toEqual(PRINCIPAL); + }); +}); + +describe("UserAccessResolver.enrollOnLogin", () => { + it("bypasses active cache hits, enriches/upserts and caches the freshly stored role/profile", async () => { + const { resolver, userStore, cache, enricher } = setup(); + cache.get.mockResolvedValue({ status: "hit", user: { ...PRINCIPAL, role: "user" } }); + expect(await resolver.enrollOnLogin(IDENTITY, "bearer-secret")).toEqual(PRINCIPAL); + expect(cache.get).not.toHaveBeenCalled(); + expect(userStore.findByIdentity).not.toHaveBeenCalled(); + expect(enricher.enrich).toHaveBeenCalledExactlyOnceWith(IDENTITY, "bearer-secret"); + expect(userStore.upsertOnLogin).toHaveBeenCalledExactlyOnceWith(IDENTITY, { + email: "enriched@example.com", displayName: "Enriched Name", emailVerified: true, + }); + expect(cache.set).toHaveBeenCalledExactlyOnceWith(IDENTITY, PRINCIPAL); + expect(JSON.stringify(cache.set.mock.calls)).not.toContain("bearer-secret"); + }); + + it("uses identity profile claims when no enricher is configured", async () => { + const { userStore, cache } = setup(); + const resolver = new UserAccessResolver({ userStore, cache, enricher: null }); + expect(await resolver.enrollOnLogin(IDENTITY, "raw-token")).toEqual(PRINCIPAL); + expect(userStore.upsertOnLogin).toHaveBeenCalledExactlyOnceWith(IDENTITY, { + email: IDENTITY.email, displayName: IDENTITY.displayName, emailVerified: true, + }); + }); + + it("preserves profile/upsert ordering before disabled validation but evicts cached active access", async () => { + const { resolver, userStore, cache, enricher } = setup(); + userStore.upsertOnLogin.mockResolvedValue(document({ disabledAt: new Date() })); + await expect(resolver.enrollOnLogin(IDENTITY, "raw-token")).rejects.toMatchObject({ + code: "user_disabled", status: 403, + }); + expect(enricher.enrich).toHaveBeenCalledOnce(); + expect(userStore.upsertOnLogin).toHaveBeenCalledOnce(); + expect(cache.delete).toHaveBeenCalledExactlyOnceWith(IDENTITY); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it("does not reinterpret enrichment errors as MongoDB availability failures", async () => { + const { resolver, userStore, cache, enricher } = setup(); + const error = new MongoNetworkError("not raised by the user store"); + enricher.enrich.mockRejectedValue(error); + await expect(resolver.enrollOnLogin(IDENTITY, "raw-token")).rejects.toBe(error); + expect(userStore.upsertOnLogin).not.toHaveBeenCalled(); + expect(cache.set).not.toHaveBeenCalled(); + }); +}); + +describe.each(["resolveExisting", "enrollOnLogin"] as const)("%s access validation", (method) => { + it.each([ + ["missing", null, "user_not_enrolled", 403], + ["disabled", document({ disabledAt: new Date() }), "user_disabled", 403], + ["system", document({ _id: "system" }), "invalid_principal", 401], + ["anonymous", document({ _id: "anonymous" }), "invalid_principal", 401], + ["empty ID", document({ _id: "" }), "invalid_principal", 401], + ["provider mismatch", document({ idp: "other" }), "invalid_principal", 401], + ["tenant mismatch", document({ idpTenant: "other" }), "invalid_principal", 401], + ["subject mismatch", document({ idpSubject: "other" }), "invalid_principal", 401], + ] as const)("denies %s and best-effort evicts without negative caching", async (_reason, stored, code, status) => { + const { resolver, userStore, cache } = setup(); + userStore.findByIdentity.mockResolvedValue(stored); + if (stored) userStore.upsertOnLogin.mockResolvedValue(stored); + else userStore.upsertOnLogin.mockResolvedValue(null as unknown as UserDocument); + cache.delete.mockResolvedValue({ status: "unavailable" }); + + await expect(resolver[method](IDENTITY, "raw-token")).rejects.toMatchObject({ + name: "UserAccessError", code, status, + }); + expect(cache.delete).toHaveBeenCalledExactlyOnceWith(IDENTITY); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it.each([ + new MongoNetworkError("offline"), + new MongoServerSelectionError( + "no primary", + {} as ConstructorParameters[1], + ), + new MongoNotConnectedError("not connected"), + new MongoOperationTimeoutError("deadline"), + new MongoServerError({ code: 91, message: "ShutdownInProgress" }), + ])("maps expected Mongo availability errors to 503 ($name)", async (error) => { + const { resolver, userStore, cache } = setup(); + cache.get.mockResolvedValue({ status: "unavailable" }); + userStore.findByIdentity.mockRejectedValue(error); + userStore.upsertOnLogin.mockRejectedValue(error); + await expect(resolver[method](IDENTITY, "raw-token")).rejects.toMatchObject({ + code: "service_unavailable", status: 503, + }); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it.each([ + new TypeError("programming bug"), + new Error("unknown database error"), + new MongoServerError({ code: 121, message: "validation failure" }), + new MongoServerError({ code: 11000, message: "unrelated unique-index failure" }), + Object.assign(new Error("not a real Mongo error"), { name: "MongoNetworkError" }), + ])("propagates unexpected errors unchanged ($message)", async (error) => { + const { resolver, userStore, cache } = setup(); + userStore.findByIdentity.mockRejectedValue(error); + userStore.upsertOnLogin.mockRejectedValue(error); + await expect(resolver[method](IDENTITY, "raw-token")).rejects.toBe(error); + expect(cache.set).not.toHaveBeenCalled(); + }); +}); + +describe("UserAccessError", () => { + it("allows a safe public message override and exposes its stable status/code", () => { + const error = new UserAccessError("invalid_principal", "No human principal"); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: "UserAccessError", message: "No human principal", code: "invalid_principal", status: 401, + }); + }); +}); diff --git a/apps/api/src/auth/user-access-resolver.ts b/apps/api/src/auth/user-access-resolver.ts new file mode 100644 index 000000000..c3e263727 --- /dev/null +++ b/apps/api/src/auth/user-access-resolver.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + MongoNetworkError, + MongoNotConnectedError, + MongoOperationTimeoutError, + MongoServerClosedError, + MongoServerError, + MongoServerSelectionError, + MongoTopologyClosedError, +} from "mongodb"; +import type { ProfileEnricher, UserDocument, UserProfile, VerifiedIdentity } from "shared"; +import type { AuthenticatedUser } from "./types.js"; +import { isActiveUserAccess, type UserAccessCache } from "./user-access-cache.js"; +import type { UserStore } from "./user-store.js"; + +const ACCESS_ERRORS = { + user_not_enrolled: { status: 403, message: "User is not enrolled" }, + user_disabled: { status: 403, message: "User is disabled" }, + invalid_principal: { status: 401, message: "Invalid user principal" }, + service_unavailable: { status: 503, message: "Authentication service unavailable" }, +} as const; + +export type UserAccessErrorCode = keyof typeof ACCESS_ERRORS; + +export class UserAccessError extends Error { + readonly status: 401 | 403 | 503; + + constructor(readonly code: UserAccessErrorCode, message: string = ACCESS_ERRORS[code].message) { + super(message); + this.name = "UserAccessError"; + this.status = ACCESS_ERRORS[code].status; + } +} + +export interface UserAccessResolverOptions { + userStore: Pick; + cache: UserAccessCache; + enricher: ProfileEnricher | null; +} + +/** Structural seam for middleware/routes and test doubles. */ +export interface UserAccessService { + resolveExisting(identity: VerifiedIdentity): Promise; + enrollOnLogin(identity: VerifiedIdentity, rawToken: string): Promise; +} + +const CONNECTIVITY_ERROR_CODES = new Set([ + 6, 7, 89, 91, 189, 9001, 10107, 11600, 11602, 13435, 13436, +]); + +function isMongoUnavailable(error: unknown): boolean { + return error instanceof MongoNetworkError || + error instanceof MongoServerSelectionError || + error instanceof MongoNotConnectedError || + error instanceof MongoTopologyClosedError || + error instanceof MongoServerClosedError || + error instanceof MongoOperationTimeoutError || + (error instanceof MongoServerError && typeof error.code === "number" && + CONNECTIVITY_ERROR_CODES.has(error.code)); +} + +export class UserAccessResolver implements UserAccessService { + constructor(private readonly options: UserAccessResolverOptions) {} + + async resolveExisting(identity: VerifiedIdentity): Promise { + const cached = await this.options.cache.get(identity); + if (cached.status === "hit") return cached.user; + const user = await this.readStore(() => this.options.userStore.findByIdentity(identity)); + return this.validateAndWarm(identity, user); + } + + async enrollOnLogin(identity: VerifiedIdentity, rawToken: string): Promise { + const profile: UserProfile = this.options.enricher + ? await this.options.enricher.enrich(identity, rawToken) + : { + email: identity.email, + displayName: identity.displayName, + emailVerified: identity.emailVerified, + }; + const user = await this.readStore(() => this.options.userStore.upsertOnLogin(identity, profile)); + return this.validateAndWarm(identity, user); + } + + private async readStore(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (!isMongoUnavailable(error)) throw error; + throw new UserAccessError("service_unavailable"); + } + } + + private async validateAndWarm( + identity: VerifiedIdentity, + user: UserDocument | null, + ): Promise { + let error: UserAccessError | undefined; + let principal: AuthenticatedUser | undefined; + if (!user) { + error = new UserAccessError("user_not_enrolled"); + } else { + principal = { + id: user._id, + role: user.role, + isAuthenticated: true, + isService: false, + idp: user.idp, + idpTenant: user.idpTenant, + idpSubject: user.idpSubject, + ...(user.email !== undefined ? { email: user.email } : {}), + ...(user.displayName !== undefined ? { displayName: user.displayName } : {}), + }; + if (!isActiveUserAccess(identity, principal)) { + error = new UserAccessError("invalid_principal"); + } else if (user.disabledAt) { + error = new UserAccessError("user_disabled"); + } + } + if (error || !principal) { + await this.options.cache.delete(identity); + throw error ?? new UserAccessError("invalid_principal"); + } + await this.options.cache.set(identity, principal); + return principal; + } +} diff --git a/apps/api/src/auth/user-store.test.ts b/apps/api/src/auth/user-store.test.ts new file mode 100644 index 000000000..283936480 --- /dev/null +++ b/apps/api/src/auth/user-store.test.ts @@ -0,0 +1,343 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, vi } from "vitest"; +import { MongoNetworkError, MongoServerError, type Collection } from "mongodb"; +import type { UserDocument, UserProfile, VerifiedIdentity } from "shared"; +import { UserStore } from "./user-store.js"; + +const IDENTITY: VerifiedIdentity = { + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", + email: "user@example.com", + displayName: "Test User", + emailVerified: true, +}; + +const PROFILE: UserProfile = { + email: "user@example.com", + displayName: "Test User", + emailVerified: true, +}; + +function baseDoc(overrides: Partial = {}): UserDocument { + return { + _id: "user-uuid-1", + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", + email: "user@example.com", + displayName: "Test User", + role: "user", + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as UserDocument; +} + +/** Fake collection whose findOneAndUpdate returns queued documents in order. */ +function fakeCollection(queue: (UserDocument | null)[]) { + const findOneAndUpdate = vi.fn(async ( + _filter: unknown, _update: unknown, _options: unknown, + ) => queue.shift() ?? null); + const findOne = vi.fn<(filter: unknown) => Promise>(async () => null); + const collection = { findOneAndUpdate, findOne } as unknown as + Collection; + return { collection, findOneAndUpdate, findOne }; +} + +describe("UserStore.upsertOnLogin", () => { + it("upserts by the identity triple with app-owned insert defaults", async () => { + const { collection, findOneAndUpdate } = fakeCollection([baseDoc()]); + const store = new UserStore(collection); + + const user = await store.upsertOnLogin(IDENTITY, PROFILE); + + expect(user.role).toBe("user"); + expect(findOneAndUpdate).toHaveBeenCalledOnce(); + const [filter, update, options] = findOneAndUpdate.mock.calls[0]; + expect(filter).toEqual({ + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", + }); + expect(options).toMatchObject({ upsert: true, returnDocument: "after" }); + + const u = update as { + $set: Record; + $setOnInsert: Record; + }; + expect(u.$set.email).toBe("user@example.com"); + expect(u.$set.displayName).toBe("Test User"); + expect(u.$set.lastLoginAt).toBeInstanceOf(Date); + expect(u.$setOnInsert.role).toBe("user"); + expect(typeof u.$setOnInsert._id).toBe("string"); + expect(u.$setOnInsert.createdAt).toBeInstanceOf(Date); + // The insert must not carry idp fields in $set (they belong to $setOnInsert + // + the filter) to avoid a Mongo path conflict. + expect(u.$set.idp).toBeUndefined(); + }); + + it("does not clobber known values when a profile claim is missing", async () => { + const { collection, findOneAndUpdate } = fakeCollection([baseDoc()]); + const store = new UserStore(collection); + + await store.upsertOnLogin(IDENTITY, { displayName: "Only Name" }); + + const update = findOneAndUpdate.mock.calls[0][1] as { + $set: Record; + }; + expect(update.$set.displayName).toBe("Only Name"); + expect("email" in update.$set).toBe(false); + expect("emailVerified" in update.$set).toBe(false); + }); + + it("does not persist an email that is not verified", async () => { + const { collection, findOneAndUpdate } = fakeCollection([baseDoc()]); + const store = new UserStore(collection); + + await store.upsertOnLogin(IDENTITY, { + email: "unverified@example.com", + emailVerified: false, + }); + + const update = findOneAndUpdate.mock.calls[0][1] as { + $set: Record; + }; + expect("email" in update.$set).toBe(false); + expect(update.$set.emailVerified).toBe(false); + }); + + it("promotes a bootstrap admin (promote-only)", async () => { + const { collection, findOneAndUpdate } = fakeCollection([ + baseDoc({ role: "user" }), + baseDoc({ role: "admin" }), + ]); + const store = new UserStore(collection, { + bootstrapAdmins: new Set(["entra:tenant-1/subject-1"]), + bootstrapTenants: new Set(["tenant-1"]), + }); + + const user = await store.upsertOnLogin(IDENTITY, PROFILE); + + expect(user.role).toBe("admin"); + expect(findOneAndUpdate).toHaveBeenCalledTimes(2); + const promote = findOneAndUpdate.mock.calls[1][1] as { + $set: Record; + }; + expect(promote.$set.role).toBe("admin"); + }); + + it("does not re-promote an existing admin", async () => { + const { collection, findOneAndUpdate } = fakeCollection([ + baseDoc({ role: "admin" }), + ]); + const store = new UserStore(collection, { + bootstrapAdmins: new Set(["entra:tenant-1/subject-1"]), + bootstrapTenants: new Set(["tenant-1"]), + }); + + const user = await store.upsertOnLogin(IDENTITY, PROFILE); + + expect(user.role).toBe("admin"); + expect(findOneAndUpdate).toHaveBeenCalledOnce(); + }); + + it("gates bootstrap by a non-empty tenant allowlist", async () => { + const { collection, findOneAndUpdate } = fakeCollection([ + baseDoc({ role: "user" }), + ]); + const store = new UserStore(collection, { + bootstrapAdmins: new Set(["entra:tenant-1/subject-1"]), + bootstrapTenants: new Set(["other-tenant"]), + }); + + const user = await store.upsertOnLogin(IDENTITY, PROFILE); + + expect(user.role).toBe("user"); + expect(findOneAndUpdate).toHaveBeenCalledOnce(); + }); + + it("promotes when the tenant is in the allowlist", async () => { + const { collection, findOneAndUpdate } = fakeCollection([ + baseDoc({ role: "user" }), + baseDoc({ role: "admin" }), + ]); + const store = new UserStore(collection, { + bootstrapAdmins: new Set(["entra:tenant-1/subject-1"]), + bootstrapTenants: new Set(["tenant-1"]), + }); + + const user = await store.upsertOnLogin(IDENTITY, PROFILE); + + expect(user.role).toBe("admin"); + expect(findOneAndUpdate).toHaveBeenCalledTimes(2); + }); + + it("does not promote when the tenant allowlist is empty", async () => { + const { collection, findOneAndUpdate } = fakeCollection([ + baseDoc({ role: "user" }), + ]); + const store = new UserStore(collection, { + bootstrapAdmins: new Set(["entra:tenant-1/subject-1"]), + }); + + const user = await store.upsertOnLogin(IDENTITY, PROFILE); + + expect(user.role).toBe("user"); + expect(findOneAndUpdate).toHaveBeenCalledOnce(); + }); + + it.each([false, undefined])("promotes an allowlisted identity with emailVerified=%s", async (emailVerified) => { + const { collection, findOneAndUpdate } = fakeCollection([ + baseDoc({ role: "user" }), + baseDoc({ role: "admin" }), + ]); + const store = new UserStore(collection, { + bootstrapAdmins: new Set(["entra:tenant-1/subject-1"]), + bootstrapTenants: new Set(["tenant-1"]), + }); + + const user = await store.upsertOnLogin( + { ...IDENTITY, emailVerified }, + { ...PROFILE, emailVerified }, + ); + + expect(user.role).toBe("admin"); + expect(findOneAndUpdate).toHaveBeenCalledTimes(2); + const update = findOneAndUpdate.mock.calls[0][1]; + expect(update).not.toHaveProperty("$set.email"); + expect(update).not.toHaveProperty("$set.emailVerified", true); + }); + + it.each([ + "entra:tenant-1/other-subject", + "entra:other-tenant/subject-1", + "other-idp:tenant-1/subject-1", + ])("does not promote when only a different identity is allowlisted (%s)", async (bootstrapAdmin) => { + const { collection, findOneAndUpdate } = fakeCollection([baseDoc()]); + const store = new UserStore(collection, { + bootstrapAdmins: new Set([bootstrapAdmin]), + bootstrapTenants: new Set(["tenant-1"]), + }); + + const user = await store.upsertOnLogin(IDENTITY, PROFILE); + + expect(user.role).toBe("user"); + expect(findOneAndUpdate).toHaveBeenCalledOnce(); + }); + + it("throws when the upsert returns nothing", async () => { + const { collection } = fakeCollection([null]); + const store = new UserStore(collection); + + await expect(store.upsertOnLogin(IDENTITY, PROFILE)).rejects.toThrow(); + }); + + it("retries only the update when a concurrent login wins the unique identity insert", async () => { + const { collection, findOneAndUpdate } = fakeCollection([baseDoc({ role: "admin" })]); + findOneAndUpdate.mockRejectedValueOnce(new MongoServerError({ + code: 11000, + keyPattern: { idp: 1, idpTenant: 1, idpSubject: 1 }, + keyValue: { idp: IDENTITY.idp, idpTenant: IDENTITY.idpTenant, idpSubject: IDENTITY.idpSubject }, + })); + const store = new UserStore(collection); + + expect(await store.upsertOnLogin(IDENTITY, PROFILE)).toMatchObject({ + _id: "user-uuid-1", role: "admin", + }); + const [filter, update, options] = findOneAndUpdate.mock.calls[1]; + expect(filter).toEqual(findOneAndUpdate.mock.calls[0][0]); + expect(options).toEqual({ upsert: false, returnDocument: "after" }); + expect(update).toEqual({ + $set: expect.objectContaining({ + email: PROFILE.email, + displayName: PROFILE.displayName, + lastLoginAt: expect.any(Date), + }), + }); + }); + + it("recognizes the exact named identity index when Cosmos omits index metadata", async () => { + const { collection, findOneAndUpdate } = fakeCollection([baseDoc()]); + findOneAndUpdate.mockRejectedValueOnce(new MongoServerError({ + code: 11000, message: "E11000 duplicate key error index: uniq_identity dup key: {}", + })); + await expect(new UserStore(collection).upsertOnLogin(IDENTITY, PROFILE)).resolves.toEqual(baseDoc({ + createdAt: expect.any(Date), updatedAt: expect.any(Date), + })); + expect(findOneAndUpdate).toHaveBeenCalledTimes(2); + }); + + it.each([ + new MongoNetworkError("connection lost"), + new MongoServerError({ code: 11000, keyPattern: { _id: 1 } }), + new MongoServerError({ code: 11000, message: "duplicate key" }), + new MongoServerError({ code: 11000, message: "index: uniq_identity_extra dup key: {}" }), + new MongoServerError({ + code: 11000, keyPattern: { idp: 1, idpTenant: 1, idpSubject: 1 }, + keyValue: { idp: "entra", idpTenant: "another-tenant", idpSubject: "subject-1" }, + }), + new MongoServerError({ code: 121, keyPattern: { idp: 1, idpTenant: 1, idpSubject: 1 } }), + Object.assign(new Error("duplicate key"), { + code: 11000, keyPattern: { idp: 1, idpTenant: 1, idpSubject: 1 }, + }), + ])("does not retry unrelated failures ($name)", async (error) => { + const { collection, findOneAndUpdate } = fakeCollection([]); + findOneAndUpdate.mockRejectedValueOnce(error); + await expect(new UserStore(collection).upsertOnLogin(IDENTITY, PROFILE)).rejects.toBe(error); + expect(findOneAndUpdate).toHaveBeenCalledOnce(); + }); + + it("does not loop if the identity collision retry fails or no longer finds the winner", async () => { + const { collection, findOneAndUpdate } = fakeCollection([null]); + const error = new MongoServerError({ + code: 11000, keyPattern: { idp: 1, idpTenant: 1, idpSubject: 1 }, + }); + findOneAndUpdate.mockRejectedValueOnce(error); + await expect(new UserStore(collection).upsertOnLogin(IDENTITY, PROFILE)).rejects.toBe(error); + expect(findOneAndUpdate).toHaveBeenCalledTimes(2); + }); +}); + +describe("UserStore.findByIdentity", () => { + it("looks up the exact identity triple without writing login or profile data", async () => { + const { collection, findOne, findOneAndUpdate } = fakeCollection([]); + const doc = baseDoc(); + findOne.mockResolvedValueOnce(doc); + const store = new UserStore(collection); + + expect(await store.findByIdentity(IDENTITY)).toBe(doc); + expect(findOne).toHaveBeenCalledExactlyOnceWith({ + idp: "entra", idpTenant: "tenant-1", idpSubject: "subject-1", + }); + expect(findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it("returns null for a missing identity without enrolling", async () => { + const { collection, findOneAndUpdate } = fakeCollection([]); + expect(await new UserStore(collection).findByIdentity(IDENTITY)).toBeNull(); + expect(findOneAndUpdate).not.toHaveBeenCalled(); + }); + + it("preserves database errors for the resolver to classify", async () => { + const { collection, findOne } = fakeCollection([]); + const error = new MongoNetworkError("unreachable"); + findOne.mockRejectedValueOnce(error); + await expect(new UserStore(collection).findByIdentity(IDENTITY)).rejects.toBe(error); + }); +}); + +describe("UserStore.findById", () => { + it("looks up by _id", async () => { + const { collection, findOne } = fakeCollection([]); + (findOne as ReturnType).mockResolvedValueOnce(baseDoc()); + const store = new UserStore(collection); + + const user = await store.findById("user-uuid-1"); + + expect(user?._id).toBe("user-uuid-1"); + expect(findOne).toHaveBeenCalledWith({ _id: "user-uuid-1" }); + }); +}); diff --git a/apps/api/src/auth/user-store.ts b/apps/api/src/auth/user-store.ts new file mode 100644 index 000000000..06e032fe2 --- /dev/null +++ b/apps/api/src/auth/user-store.ts @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { MongoServerError, type Collection, type UpdateFilter } from "mongodb"; +import { + bootstrapAdminKey, + type UserDocument, + type UserProfile, + type VerifiedIdentity, +} from "shared"; + +export interface UserStoreOptions { + /** Identity keys (`${idp}:${tenant}/${subject}`) to promote to admin. */ + bootstrapAdmins?: Set; + /** Tenant allowlist gating bootstrap promotion (empty = no promotion). */ + bootstrapTenants?: Set; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isIdentityUpsertCollision(error: unknown, identity: VerifiedIdentity): boolean { + if (!(error instanceof MongoServerError) || error.code !== 11000) return false; + const pattern: unknown = error.keyPattern; + const value: unknown = error.keyValue; + if (isRecord(value) && ( + value.idp !== identity.idp || + value.idpTenant !== identity.idpTenant || + value.idpSubject !== identity.idpSubject + )) return false; + + if (isRecord(pattern)) { + return Object.keys(pattern).length === 3 && + pattern.idp === 1 && pattern.idpTenant === 1 && pattern.idpSubject === 1; + } + // CosmosDB may omit keyPattern/keyValue but still identify the known index. + return /\bindex:\s+uniq_identity\s+dup key:/.test(error.message); +} + +/** + * Persistence for the `users` collection. + * + * On each explicit login upsert the identity triple is persisted (Just-In-Time + * provisioning): a new user is minted a Scope User ID (UUID) with the default + * role `"user"`, and an existing user has its profile (`email`, `displayName`, + * `emailVerified`) and `lastLoginAt` refreshed. Email is persisted only when + * explicitly verified. Admin bootstrap is **promote-only** and requires an + * exact identity match and membership in the configured tenant allowlist. + */ +export class UserStore { + private readonly bootstrapAdmins: Set; + private readonly bootstrapTenants: Set; + + constructor( + private readonly collection: Collection, + options: UserStoreOptions = {}, + ) { + this.bootstrapAdmins = options.bootstrapAdmins ?? new Set(); + this.bootstrapTenants = options.bootstrapTenants ?? new Set(); + } + + /** JIT-upsert the user for a verified identity and return the stored record. */ + async upsertOnLogin( + identity: VerifiedIdentity, + profile: UserProfile, + ): Promise { + const now = new Date(); + + const set: Record = { + updatedAt: now, + lastLoginAt: now, + }; + // Only refresh fields the profile actually provided, so a token missing a + // claim never clobbers a previously-known value. + if (profile.displayName !== undefined) set.displayName = profile.displayName; + if (profile.emailVerified === true) { + if (profile.email !== undefined) set.email = profile.email; + set.emailVerified = true; + } else if (profile.emailVerified === false) { + set.emailVerified = profile.emailVerified; + } + + const setOnInsert: Record = { + _id: randomUUID(), + idp: identity.idp, + idpTenant: identity.idpTenant, + idpSubject: identity.idpSubject, + role: "user", + createdAt: now, + }; + + const update = { $set: set, $setOnInsert: setOnInsert } as unknown as + UpdateFilter; + + const filter = { + idp: identity.idp, + idpTenant: identity.idpTenant, + idpSubject: identity.idpSubject, + }; + let result: UserDocument | null; + try { + result = await this.collection.findOneAndUpdate( + filter, + update, + { upsert: true, returnDocument: "after" }, + ); + } catch (error) { + if (!isIdentityUpsertCollision(error, identity)) throw error; + // Another login inserted this exact identity. Refresh it without retrying + // the insert or replacing the winner's app-owned ID and role. + result = await this.collection.findOneAndUpdate( + filter, + { $set: set } as UpdateFilter, + { upsert: false, returnDocument: "after" }, + ); + if (!result) throw error; + } + + if (!result) { + throw new Error("Failed to upsert user during login"); + } + let user = result as UserDocument; + + if (this.shouldBootstrapAdmin(identity) && user.role !== "admin") { + const promoted = await this.collection.findOneAndUpdate( + { _id: user._id } as unknown as UpdateFilter, + { $set: { role: "admin", updatedAt: new Date() } } as unknown as + UpdateFilter, + { returnDocument: "after" }, + ); + if (promoted) { + user = promoted as UserDocument; + } + } + + return user; + } + + /** Read an existing identity without provisioning or refreshing login/profile fields. */ + async findByIdentity(identity: VerifiedIdentity): Promise { + return this.collection.findOne({ + idp: identity.idp, + idpTenant: identity.idpTenant, + idpSubject: identity.idpSubject, + }); + } + + /** Look up a user by Scope User ID. */ + async findById(id: string): Promise { + const doc = await this.collection.findOne({ + _id: id, + } as unknown as Parameters["findOne"]>[0]); + return (doc as UserDocument | null) ?? null; + } + + private shouldBootstrapAdmin(identity: VerifiedIdentity): boolean { + const key = bootstrapAdminKey( + identity.idp, + identity.idpTenant, + identity.idpSubject, + ); + if (!this.bootstrapAdmins.has(key)) return false; + return this.bootstrapTenants.has(identity.idpTenant); + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1bb32ef04..9017fba42 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,9 +9,14 @@ import { DefaultAzureCredential } from "@azure/identity"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; -import { TaskPromptStore, SkillRevisionStore, SkillResolver, CodebaseStore, CodebaseRevisionStore, CodebaseResolver, McpSecretClient, McpSecretUnavailableError, BlobStorage, RedisHeartbeatStore, ProjectStore } from "shared"; +import { TaskPromptStore, SkillRevisionStore, SkillResolver, CodebaseStore, CodebaseRevisionStore, CodebaseResolver, McpSecretClient, McpSecretUnavailableError, BlobStorage, RedisHeartbeatStore, ProjectStore, loadAuthConfigFromEnv } from "shared"; import { initTelemetry } from "telemetry"; -import type { TaskPromptDocument, SkillDocument, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, ProfileDocument, ProfileVersionDocument, ProjectDocument, HeartbeatStore } from "shared"; +import type { TaskPromptDocument, SkillDocument, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, ProfileDocument, ProfileVersionDocument, ProjectDocument, HeartbeatStore, AuthProvider, ProfileEnricher, UserDocument } from "shared"; +import { UserStore } from "./auth/user-store.js"; +import { createAuthMiddleware, createUserAccessMiddleware } from "./auth/middleware.js"; +import { authErrorHandler } from "./auth/error-handler.js"; +import { RedisUserAccessCache, type UserAccessCache } from "./auth/user-access-cache.js"; +import { UserAccessResolver, type UserAccessService } from "./auth/user-access-resolver.js"; import { acquireGitHubPublicApiToken } from "./github-api-token.js"; import { generateOpenAPIDocument, registry } from "./openapi/index.js"; import swaggerUi from "swagger-ui-express"; @@ -42,6 +47,7 @@ import { registerSecretsRoutes } from "./routes/secrets.js"; import { registerProfilesRoutes } from "./routes/profiles.js"; import { registerProjectsRoutes } from "./routes/projects.js"; import { ProjectScopeError } from "./utils/project-scope.js"; +import { registerUsersRoutes } from "./routes/users.js"; import type { RouteContext } from "./route-context.js"; import type { CriteriaDocument, @@ -108,6 +114,12 @@ let skillRevisionCollection: Collection; let skillRevisionStore: SkillRevisionStore; let profileCollection: Collection; let profileVersionCollection: Collection; +let usersCollection: Collection; +let authProvider: AuthProvider | null = null; +let profileEnricher: ProfileEnricher | null = null; +let userStore: UserStore | null = null; +let userAccessCache: UserAccessCache | null = null; +let userAccessResolver: UserAccessService | null = null; let skillResolver: SkillResolver; let codebaseCollection: Collection; let codebaseRevisionCollection: Collection; @@ -151,6 +163,33 @@ async function initializeClients(): Promise { profileCollection = db.collection("profiles"); profileVersionCollection = db.collection("profile-versions"); + usersCollection = db.collection("users"); + const authRuntime = loadAuthConfigFromEnv(); + if (authRuntime) { + authProvider = authRuntime.provider; + profileEnricher = authRuntime.enricher; + userStore = new UserStore(usersCollection, { + bootstrapAdmins: authRuntime.bootstrapAdmins, + bootstrapTenants: authRuntime.bootstrapTenants, + }); + userAccessCache = new RedisUserAccessCache({ + redisHost: process.env.REDIS_HOST || "", + redisPort: parseInt(process.env.REDIS_PORT || "6379", 10), + redisPassword: process.env.REDIS_PASSWORD || "", + }, { + ttlSeconds: authRuntime.userCacheTtlSeconds, + namespace: mongoDatabase, + }); + userAccessResolver = new UserAccessResolver({ + userStore, + cache: userAccessCache, + enricher: profileEnricher, + }); + console.log(`Auth enabled: provider=${authProvider.id}`); + } else { + console.log("Auth not configured — all requests will be treated as anonymous"); + } + codebaseCollection = db.collection("codebases"); codebaseRevisionCollection = db.collection("codebase-revisions"); codebaseStore = new CodebaseStore(codebaseCollection); @@ -237,6 +276,11 @@ const routeCtx: RouteContext = { get insightsCollection() { return insightsCollection; }, get profileCollection() { return profileCollection; }, get profileVersionCollection() { return profileVersionCollection; }, + get usersCollection() { return usersCollection; }, + get userStore() { return userStore; }, + get userAccessResolver() { return userAccessResolver; }, + get authProvider() { return authProvider; }, + get profileEnricher() { return profileEnricher; }, get taskPromptCollection() { return taskPromptCollection; }, get featureFlagCollection() { return featureFlagCollection; }, get skillCollection() { return skillCollection; }, @@ -260,6 +304,18 @@ const routeCtx: RouteContext = { }; // ─── Route registration ─────────────────────────────────────────────────────── +app.use("/api/v1/users/me", (_req, res, next) => { + res.setHeader("Cache-Control", "no-store"); + next(); +}); +app.use( + createAuthMiddleware({ + getProvider: () => authProvider, + }), +); +registerUsersRoutes(routeCtx); +app.use(createUserAccessMiddleware(() => userAccessResolver)); + // Secrets/proxy routes must be registered first (before :id param routes) registerSecretsRoutes(routeCtx); registerSystemRoutes(routeCtx); @@ -289,6 +345,7 @@ registerInsightsRoutes(routeCtx); registerFeatureFlagRoutes(routeCtx); // Error handler +app.use(authErrorHandler); app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof ProjectScopeError) { res.status(err.status).json({ error: err.message }); @@ -312,9 +369,30 @@ async function main(): Promise { }); app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openapiDocument)); - app.listen(port, () => { + const server = app.listen(port, () => { console.log(`API server listening on port ${port}`); }); + let shuttingDown = false; + const shutdown = () => { + if (shuttingDown) return; + shuttingDown = true; + server.close((error) => { + if (error) { + console.error("Failed to close API server:", error); + process.exitCode = 1; + } + void Promise.all([ + userAccessCache?.close(), + heartbeatStore.close(), + mongoClient.close(), + ]).catch((closeError: unknown) => { + console.error("Failed to close API dependencies:", closeError); + process.exitCode = 1; + }); + }); + }; + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); } // ─── Test support ──────────────────────────────────────────────────────────── @@ -335,6 +413,11 @@ export interface TestDependencies { insightsCollection?: Collection; profileCollection?: Collection; profileVersionCollection?: Collection; + usersCollection?: Collection; + userStore?: UserStore | null; + userAccessResolver?: UserAccessService | null; + authProvider?: AuthProvider | null; + profileEnricher?: ProfileEnricher | null; taskPromptCollection?: Collection; taskPromptStore?: TaskPromptStore; featureFlagCollection?: Collection; @@ -366,6 +449,11 @@ export function _injectTestDependencies(deps: TestDependencies): void { if (deps.insightsCollection) insightsCollection = deps.insightsCollection; if (deps.profileCollection) profileCollection = deps.profileCollection; if (deps.profileVersionCollection) profileVersionCollection = deps.profileVersionCollection; + if (deps.usersCollection) usersCollection = deps.usersCollection; + if (deps.userStore !== undefined) userStore = deps.userStore; + if (deps.userAccessResolver !== undefined) userAccessResolver = deps.userAccessResolver; + if (deps.authProvider !== undefined) authProvider = deps.authProvider; + if (deps.profileEnricher !== undefined) profileEnricher = deps.profileEnricher; if (deps.taskPromptCollection) taskPromptCollection = deps.taskPromptCollection; if (deps.taskPromptStore) taskPromptStore = deps.taskPromptStore; if (deps.featureFlagCollection) featureFlagCollection = deps.featureFlagCollection; diff --git a/apps/api/src/openapi-snapshot.test.ts b/apps/api/src/openapi-snapshot.test.ts index 7b6d6bf7b..407bb4062 100644 --- a/apps/api/src/openapi-snapshot.test.ts +++ b/apps/api/src/openapi-snapshot.test.ts @@ -35,6 +35,25 @@ describe("OpenAPI spec snapshot", () => { const { generateOpenAPIDocument } = await import("./openapi/index.js"); const doc = generateOpenAPIDocument(); + expect(doc.components?.securitySchemes?.bearerAuth).toMatchObject({ + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + }); + expect(doc.paths?.["/api/v1/users/me"]?.get?.security).toEqual([{ bearerAuth: [] }]); + expect(doc.paths?.["/api/v1/users/me"]?.post?.security).toEqual([{ bearerAuth: [] }]); + expect(doc.paths?.["/api/v1/users/me"]?.post?.responses).toHaveProperty("200"); + expect(doc).not.toHaveProperty("security"); + const securedOperations: string[] = []; + for (const [path, item] of Object.entries(doc.paths ?? {})) { + for (const method of ["get", "post", "put", "patch", "delete", "head", "options", "trace"] as const) { + if (item?.[method]?.security !== undefined) { + securedOperations.push(`${method.toUpperCase()} ${path}`); + } + } + } + expect(securedOperations).toEqual(["GET /api/v1/users/me", "POST /api/v1/users/me"]); + // Snapshot the full spec — catches dropped routes, changed schemas, etc. expect(doc).toMatchSnapshot(); diff --git a/apps/api/src/openapi/api-route.test.ts b/apps/api/src/openapi/api-route.test.ts index cbc392da1..d4b80d3c0 100644 --- a/apps/api/src/openapi/api-route.test.ts +++ b/apps/api/src/openapi/api-route.test.ts @@ -63,6 +63,30 @@ describe("apiRoute — OpenAPI registration", () => { expect(doc.paths?.["/api/v1/items"]?.get).toBeDefined(); expect(doc.paths?.["/api/v1/items"]?.get?.tags).toEqual(["Items"]); expect(doc.paths?.["/api/v1/items"]?.get?.summary).toBe("List items"); + expect(doc.paths?.["/api/v1/items"]?.get).not.toHaveProperty("security"); + expect(doc).not.toHaveProperty("security"); + }); + + it.each([ + { name: "bearer authentication", security: [{ bearerAuth: [] }] }, + { name: "an explicit anonymous override", security: [] }, + ])("forwards $name as operation-scoped documentation only", async ({ security }) => { + apiRoute(app, registry, { + method: "get", + path: "/api/v1/security-docs", + tags: ["Test"], + summary: "Security documentation", + security, + response: z.object({ ok: z.boolean() }), + handler: (_req, res) => { + res.json({ ok: true }); + }, + }); + + const doc = generateDoc(registry); + expect(doc.paths?.["/api/v1/security-docs"]?.get?.security).toEqual(security); + expect(doc).not.toHaveProperty("security"); + expect((await request(app).get("/api/v1/security-docs")).status).toBe(200); }); it("registers path params in OpenAPI format", () => { diff --git a/apps/api/src/openapi/api-route.ts b/apps/api/src/openapi/api-route.ts index 18730dd32..584e09bad 100644 --- a/apps/api/src/openapi/api-route.ts +++ b/apps/api/src/openapi/api-route.ts @@ -3,7 +3,7 @@ import { z, type ZodType, type ZodObject } from "zod"; import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; -import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import type { OpenAPIRegistry, RouteConfig } from "@asteasolutions/zod-to-openapi"; import type { Express, Request, Response, NextFunction, RequestHandler } from "express"; extendZodWithOpenApi(z); @@ -34,6 +34,9 @@ export interface ApiRouteConfig< summary: string; description?: string; + /** OpenAPI-only security requirements; enforcement remains in auth middleware/handlers. */ + security?: RouteConfig["security"]; + // Schemas (all optional) body?: TBody; query?: TQuery; @@ -134,6 +137,7 @@ export function apiRoute< tags, summary, description, + security, body, query, params, @@ -190,6 +194,7 @@ export function apiRoute< tags, summary, ...(description ? { description } : {}), + ...(security !== undefined ? { security } : {}), ...(Object.keys(request).length > 0 ? { request } : {}), responses, } as Parameters[0]); diff --git a/apps/api/src/openapi/registry.ts b/apps/api/src/openapi/registry.ts index 785eaa8d9..0f9fc58ee 100644 --- a/apps/api/src/openapi/registry.ts +++ b/apps/api/src/openapi/registry.ts @@ -8,6 +8,13 @@ import { export const registry = new OpenAPIRegistry(); +registry.registerComponent("securitySchemes", "bearerAuth", { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Use the unchanged IdP access token, without the Bearer prefix.", +}); + export function generateOpenAPIDocument() { const generator = new OpenApiGeneratorV31(registry.definitions); return generator.generateDocument({ diff --git a/apps/api/src/route-context.ts b/apps/api/src/route-context.ts index 7d74ab122..6a337a782 100644 --- a/apps/api/src/route-context.ts +++ b/apps/api/src/route-context.ts @@ -8,6 +8,8 @@ import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import type { QueueClient } from "@azure/storage-queue"; import type { BlobStorage } from "shared"; import type { HeartbeatStore } from "shared"; +import type { UserStore } from "./auth/user-store.js"; +import type { UserAccessService } from "./auth/user-access-resolver.js"; import type { TaskPromptStore, TaskPromptDocument, @@ -25,6 +27,9 @@ import type { ProfileVersionDocument, ProjectStore, ProjectDocument, + UserDocument, + AuthProvider, + ProfileEnricher, // Zod response schemas → inferred types replace hand-written interfaces CriteriaResponseSchema, ExtensionResponseSchema, @@ -98,6 +103,7 @@ export interface RouteContext { skillRevisionCollection: Collection; profileCollection: Collection; profileVersionCollection: Collection; + usersCollection: Collection; codebaseCollection: Collection; codebaseRevisionCollection: Collection; @@ -113,6 +119,11 @@ export interface RouteContext { // Token Manager client (null when TOKEN_MANAGER_URL not set) mcpSecretClient: McpSecretClient | null; + authProvider: AuthProvider | null; + profileEnricher: ProfileEnricher | null; + userStore: UserStore | null; + userAccessResolver: UserAccessService | null; + // Blob storage (log persistence + snapshots) blobStorage: BlobStorage; diff --git a/apps/api/src/routes/users.test.ts b/apps/api/src/routes/users.test.ts new file mode 100644 index 000000000..0ba2a5e91 --- /dev/null +++ b/apps/api/src/routes/users.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import request from "supertest"; +import { AuthError, type VerifiedIdentity } from "shared"; +import { app, _injectTestDependencies } from "../index.js"; +import { createAllMockDependencies } from "../test-helpers.js"; +import { UserAccessError, type UserAccessService } from "../auth/user-access-resolver.js"; + +vi.mock("db-migrations/check-migrations", () => ({ + checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001"], pending: [] }), +})); +vi.mock("../llm.js", () => ({ + isLlmAvailable: vi.fn().mockReturnValue(false), + generateCriteriaPrompt: vi.fn(), +})); +vi.mock("../prompt-feature-llm.js", () => ({ + isLlmAvailable: vi.fn().mockReturnValue(false), + generatePromptFeaturePrompt: vi.fn(), + extractPromptFeatures: vi.fn(), +})); +vi.mock("../task-prompt-llm.js", () => ({ + isTaskPromptLlmAvailable: vi.fn().mockReturnValue(false), + generateTaskPrompt: vi.fn(), +})); + +const identity: VerifiedIdentity = { + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", + email: "user@example.com", + displayName: "Test User", + emailVerified: true, +}; +const principal = { + id: "user-uuid-1", + role: "user", + isAuthenticated: true, + ...identity, +}; +const provider = { + id: "entra", + verifyAccessToken: vi.fn(async () => identity), +}; +const resolver = { + resolveExisting: vi.fn(async () => principal), + enrollOnLogin: vi.fn(async () => principal), +} satisfies UserAccessService; + +describe("/api/v1/users/me", () => { + beforeEach(() => { + vi.resetAllMocks(); + provider.verifyAccessToken.mockResolvedValue(identity); + resolver.resolveExisting.mockResolvedValue(principal); + resolver.enrollOnLogin.mockResolvedValue(principal); + _injectTestDependencies(createAllMockDependencies()); + _injectTestDependencies({ authProvider: provider, userAccessResolver: resolver }); + }); + + it.each([ + ["GET", () => request(app).get("/api/v1/users/me")], + ["POST", () => request(app).post("/api/v1/users/me")], + ])("rejects anonymous %s requests without writes", async (_method, send) => { + const res = await send(); + expect(res.status).toBe(401); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + }); + + it("returns the resolved Scope identity without enrolling on GET", async () => { + const res = await request(app).get("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.body).toEqual({ + id: "user-uuid-1", + role: "user", + email: "user@example.com", + displayName: "Test User", + idp: "entra", + idpTenant: "tenant-1", + }); + expect(provider.verifyAccessToken).toHaveBeenCalledExactlyOnceWith("token"); + expect(resolver.resolveExisting).toHaveBeenCalledExactlyOnceWith(identity); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }); + + it("enrolls on POST without a preceding existing-user lookup", async () => { + resolver.enrollOnLogin.mockResolvedValue({ ...principal, role: "admin" }); + const res = await request(app).post("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(200); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(res.body.role).toBe("admin"); + expect(resolver.enrollOnLogin).toHaveBeenCalledExactlyOnceWith(identity, "token"); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + expect(provider.verifyAccessToken).toHaveBeenCalledOnce(); + }); + it("requires an initialized resolver for GET", async () => { + _injectTestDependencies({ userAccessResolver: null }); + const res = await request(app).get("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(503); + expect(res.headers["cache-control"]).toBe("no-store"); + }); + + it("requires an initialized resolver for POST", async () => { + _injectTestDependencies({ userAccessResolver: null }); + const res = await request(app).post("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(503); + expect(res.headers["cache-control"]).toBe("no-store"); + }); + + it("rejects bad tokens before GET access resolution", async () => { + provider.verifyAccessToken.mockRejectedValue(new AuthError("invalid_token", "bad")); + const res = await request(app).get("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(401); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }); + + it("rejects bad tokens before POST enrollment", async () => { + provider.verifyAccessToken.mockRejectedValue(new AuthError("invalid_token", "bad")); + const res = await request(app).post("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(401); + expect(res.headers["cache-control"]).toBe("no-store"); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }); + + it.each(["user_not_enrolled", "user_disabled"] as const)("reports %s on read", async (code) => { + resolver.resolveExisting.mockRejectedValue(new UserAccessError(code)); + const res = await request(app).get("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(403); + expect(res.body.code).toBe(code); + expect(resolver.enrollOnLogin).not.toHaveBeenCalled(); + }); + + it("propagates enrollment access denial and does not fall through to other middleware", async () => { + resolver.enrollOnLogin.mockRejectedValue(new UserAccessError("user_disabled")); + const res = await request(app).post("/api/v1/users/me").set("Authorization", "Bearer token"); + expect(res.status).toBe(403); + expect(res.body.code).toBe("user_disabled"); + expect(resolver.resolveExisting).not.toHaveBeenCalled(); + }); + +}); diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts new file mode 100644 index 000000000..1e6dae4ff --- /dev/null +++ b/apps/api/src/routes/users.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; +import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { AuthError } from "shared"; +import type { AuthenticatedUser } from "../auth/types.js"; +import { apiRoute } from "../openapi/api-route.js"; +import type { RouteContext } from "../route-context.js"; + +extendZodWithOpenApi(z); + +const UserMeResponseSchema = z + .object({ + id: z.string(), + role: z.string().optional(), + email: z.string().optional(), + displayName: z.string().optional(), + idp: z.string().optional(), + idpTenant: z.string().optional(), + }) + .openapi("UserMeResponse"); + +function userMeResponse(user: AuthenticatedUser): z.infer { + return { + id: user.id, + role: user.role, + email: user.email, + displayName: user.displayName, + idp: user.idp, + idpTenant: user.idpTenant, + }; +} + +export function registerUsersRoutes(ctx: RouteContext): void { + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/users/me", + tags: ["Users"], + summary: "Get the authenticated user's identity", + security: [{ bearerAuth: [] }], + description: "Read the existing authenticated Scope identity without enrollment or profile writes. Responses must not be HTTP-cached.", + response: UserMeResponseSchema, + errorResponses: { + 401: { description: "Not authenticated" }, + 403: { description: "User is not enrolled or is disabled" }, + 503: { description: "Authentication service unavailable" }, + }, + handler: async (req, res) => { + if (!req.auth) { + res.status(401).json({ error: "Authentication required" }); + return; + } + const resolver = ctx.userAccessResolver; + if (!resolver) { + throw new AuthError("service_unavailable", "Authentication service unavailable"); + } + const user = await resolver.resolveExisting(req.auth.identity); + req.user = user; + res.json(userMeResponse(user)); + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "post", + path: "/api/v1/users/me", + tags: ["Users"], + summary: "Enroll the authenticated user", + security: [{ bearerAuth: [] }], + description: "JIT-enroll the authenticated user, refresh their profile and lastLoginAt, apply bootstrap-admin rules, and warm the access cache. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.", + response: UserMeResponseSchema, + successStatus: 200, + errorResponses: { + 401: { description: "Not authenticated" }, + 403: { description: "User is disabled" }, + 503: { description: "Authentication service unavailable" }, + }, + handler: async (req, res) => { + if (!req.auth) { + res.status(401).json({ error: "Authentication required" }); + return; + } + const resolver = ctx.userAccessResolver; + if (!resolver) { + throw new AuthError("service_unavailable", "Authentication service unavailable"); + } + const user = await resolver.enrollOnLogin(req.auth.identity, req.auth.token); + req.user = user; + res.json(userMeResponse(user)); + }, + }); +} diff --git a/apps/api/src/test-helpers.ts b/apps/api/src/test-helpers.ts index 2b9ad3ca8..bfe753775 100644 --- a/apps/api/src/test-helpers.ts +++ b/apps/api/src/test-helpers.ts @@ -196,6 +196,7 @@ export function createAllMockDependencies() { const skillRevisionCollection = createMockCollection(); const profileCollection = createMockCollection(); const profileVersionCollection = createMockCollection(); + const usersCollection = createMockCollection(); const taskPromptStore = createMockTaskPromptStore(); const skillRevisionStore = createMockSkillRevisionStore(); const skillResolver = createMockSkillResolver(); @@ -220,6 +221,9 @@ export function createAllMockDependencies() { skillRevisionCollection, profileCollection, profileVersionCollection, + usersCollection, + authProvider: null, + userAccessResolver: null, taskPromptStore, skillRevisionStore, skillResolver, @@ -244,6 +248,7 @@ export function createAllMockDependencies() { skillRevisionCollection: Collection; profileCollection: Collection; profileVersionCollection: Collection; + usersCollection: Collection; taskPromptStore: ReturnType; skillRevisionStore: ReturnType; skillResolver: ReturnType; diff --git a/apps/cli/README.md b/apps/cli/README.md index 70166739a..01fd990da 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -32,6 +32,33 @@ Or pass it per-command with `-u`: scope run list -u https://your-scope-api.example.com ``` +### Authentication + +Set `SCOPE_TOKEN` to an IdP access token obtained for your API's audience. The CLI +sends that bearer unchanged; this release does not add interactive `scope auth` +commands, a Scope JWT, or token exchange. Already-enrolled callers remain compatible. + +Before a **new identity** makes ordinary authenticated calls, explicitly enroll it: + +```bash +curl --fail-with-body -sS \ + -X POST \ + -H "Authorization: Bearer $SCOPE_TOKEN" \ + -H "Cache-Control: no-store" \ + "${SCOPE_API_URL%/}/api/v1/users/me" +``` + +This POST has side effects (user/profile/`lastLoginAt`/eligible bootstrap updates): +never prefetch or poll it. `GET /api/v1/users/me` is read-only and returns +`403 user_not_enrolled` for missing enrollment or `403 user_disabled` for disabled +access; do not auto-enroll/retry these as token-refresh errors. + +The API verifies every non-public bearer before active-user resolution. Redis hits +avoid Mongo; misses/outages read the exact identity without creating users. Cache +expiry is fixed/non-sliding (300 seconds by default), so database-only role/disable +changes can remain stale until expiry. Existing public/anonymous rollout is unchanged. +Never print or persist tokens in logs. See [the auth contract](../../docs/architecture/auth-rbac.md). + ## Usage ```bash @@ -81,5 +108,6 @@ export SCOPE_NO_UPDATE_CHECK=1 |----------|-------------| | `SCOPE_API_URL` | Default API base URL | | `SCOPE_API_PORT` | Derive API URL as `http://localhost:$PORT` when `SCOPE_API_URL` is unset | +| `SCOPE_TOKEN` | Caller-provided IdP access token for authenticated API calls; new identities must explicitly enroll | | `SCOPE_NO_UPDATE_CHECK` | Set to `1` to suppress update notifications | | `GH_TOKEN` / `GITHUB_TOKEN` | GitHub token for authenticated API calls (update checks, install script) | diff --git a/apps/portal/.storybook/main.ts b/apps/portal/.storybook/main.ts index b809ea60d..f2e63c74a 100644 --- a/apps/portal/.storybook/main.ts +++ b/apps/portal/.storybook/main.ts @@ -22,6 +22,9 @@ const config: StorybookConfig = { __GIT_COMMIT__: JSON.stringify("storybook"), __BUILD_TIME__: JSON.stringify(new Date().toISOString()), __GIT_BRANCH__: JSON.stringify("storybook"), + // Auth stories use context fixtures, never a live IdP, including in builds. + "import.meta.env.VITE_AUTH_CLIENT_ID": JSON.stringify("storybook-client-id"), + "import.meta.env.VITE_AUTH_AUTHORITY": JSON.stringify("https://login.example.test/tenant"), }; return config; }, diff --git a/apps/portal/.storybook/preview.tsx b/apps/portal/.storybook/preview.tsx index a22e6ad88..0b5b9b3fa 100644 --- a/apps/portal/.storybook/preview.tsx +++ b/apps/portal/.storybook/preview.tsx @@ -4,25 +4,17 @@ import type { Preview } from "@storybook/react-vite"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; -import { PublicClientApplication } from "@azure/msal-browser"; -import { MsalProvider } from "@azure/msal-react"; import { initialize, mswLoader } from "msw-storybook-addon"; import { mswHandlers } from "./msw-handlers"; import { FeatureFlagProvider } from "../src/contexts/FeatureFlagContext"; import { ThemeProvider } from "../src/contexts/ThemeContext"; -import { AuthProvider } from "../src/contexts/AuthContext"; +import { AuthContext } from "../src/contexts/AuthContext"; +import { signedInAuth } from "../src/contexts/authFixtures"; import { ProjectProvider } from "../src/contexts/ProjectContext"; import "../src/index.css"; initialize({ onUnhandledRequest: "bypass" }); -// Un-authenticated MSAL instance so components that read auth state (e.g. the -// header UserMenu inside Layout) can render in Storybook without a live IdP. -// Stories that need a signed-in state provide their own AuthContext value. -const msalInstance = new PublicClientApplication({ - auth: { clientId: "storybook-client-id" }, -}); - const preview: Preview = { decorators: [ (Story) => { @@ -34,19 +26,17 @@ const preview: Preview = { }); return ( - - - - - - - - - - - - - + + + + + + + + + + + ); }, diff --git a/apps/portal/Dockerfile b/apps/portal/Dockerfile index 8bf850938..35e8d91f8 100644 --- a/apps/portal/Dockerfile +++ b/apps/portal/Dockerfile @@ -32,6 +32,16 @@ ENV BUILD_TIME=$BUILD_TIME ENV GIT_BRANCH=$GIT_BRANCH COPY apps/portal/ apps/portal/ +# Public MSAL settings are available to Vite only while building the bundle. +# The nginx runtime receives neither these variables nor any client secret. +ARG VITE_AUTH_CLIENT_ID +ARG VITE_AUTH_AUTHORITY +ARG VITE_AUTH_KNOWN_AUTHORITIES +ARG VITE_AUTH_SCOPES +ARG VITE_AUTH_PROTOCOL_MODE +ARG VITE_AUTH_REDIRECT_URI +ARG VITE_AUTH_POST_LOGOUT_REDIRECT_URI +ARG VITE_AUTH_CACHE_LOCATION RUN pnpm --filter portal build # Stage 2: Serve with nginx diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index a756673bd..c122dbd50 100644 --- a/apps/portal/src/App.tsx +++ b/apps/portal/src/App.tsx @@ -59,7 +59,6 @@ import { RunPreviewPanel } from "@/pages/RunPreviewPanel"; import { Admin } from "@/pages/Admin"; import { Projects } from "@/pages/Projects"; import { FeatureRoute } from "@/components/FeatureRoute"; -import { RequireAuth } from "@/components/auth/RequireAuth"; import { ProjectGate } from "@/components/ProjectGate"; import { HomeRoute } from "@/components/HomeRoute"; import { useFavicon } from "@/hooks/useFavicon"; @@ -70,11 +69,7 @@ export function App() { return ( - - - } + element={} > {/* Root is the unscoped "home": `HomeRoute` clears any active project and renders the project picker. Reaching `/` by any means (the MS diff --git a/apps/portal/src/components/Layout.stories.tsx b/apps/portal/src/components/Layout.stories.tsx index 5e16d70e6..26b93d5b0 100644 --- a/apps/portal/src/components/Layout.stories.tsx +++ b/apps/portal/src/components/Layout.stories.tsx @@ -4,11 +4,20 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent } from "storybook/test"; import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; import { Layout } from "./Layout"; const meta = { component: Layout, tags: ["ai-generated", "needs-work"], + decorators: [ + (Story) => ( + + + + ), + ], // Layout hides project-scoped nav until a project is in use, so seed one by // default; the NoProjectSelected story clears it to show the trimmed sidebar. beforeEach: () => { diff --git a/apps/portal/src/components/Layout.test.tsx b/apps/portal/src/components/Layout.test.tsx index 60ca33235..d25429341 100644 --- a/apps/portal/src/components/Layout.test.tsx +++ b/apps/portal/src/components/Layout.test.tsx @@ -5,22 +5,14 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes } from "react-router-dom"; -import { PublicClientApplication } from "@azure/msal-browser"; -import { MsalProvider } from "@azure/msal-react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@/contexts/ThemeContext"; -import { AuthProvider } from "@/contexts/AuthContext"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; import { ProjectProvider } from "@/contexts/ProjectContext"; import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; import { Layout } from "./Layout"; -// The header renders , which reads auth state via useAuth -> -// MsalProvider. Provide a minimal, un-authenticated MSAL instance so Layout can -// render in isolation without a live IdP. -const msalInstance = new PublicClientApplication({ - auth: { clientId: "test-client-id" }, -}); - // The portal defines these build-time constants via Vite `define`; the root // Vitest run doesn't apply that config, so stub them for . beforeAll(() => { @@ -44,29 +36,26 @@ function renderLayout( // default; pass { projectId: null } to exercise the no-project state. if (projectId) localStorage.setItem(PROJECT_STORAGE_KEY, projectId); // Layout now hosts (react-query + ProjectContext) and - // (MSAL + AuthContext), so the harness provides all of them - // (mirroring main.tsx). + // (AuthContext). Use an already-resolved Scope identity. const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return render( - - - - - - - }> - Home page} /> - Runs page} /> - - - - - - - + + + + + + }> + Home page} /> + Runs page} /> + + + + + + , ); } diff --git a/apps/portal/src/components/UserMenu.stories.tsx b/apps/portal/src/components/UserMenu.stories.tsx index ad5a08bed..797efc56f 100644 --- a/apps/portal/src/components/UserMenu.stories.tsx +++ b/apps/portal/src/components/UserMenu.stories.tsx @@ -3,33 +3,10 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { expect, userEvent, screen } from "storybook/test"; -import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth, signedOutAuth } from "@/contexts/authFixtures"; import { UserMenu } from "./UserMenu"; -/** Build an AuthContext value so stories are deterministic without MSAL. */ -function authValue(overrides: Partial): AuthContextValue { - return { - account: null, - user: null, - isAuthenticated: false, - isReady: true, - login: async () => {}, - logout: async () => {}, - ...overrides, - }; -} - -const signedIn = authValue({ - isAuthenticated: true, - user: { - name: "Alice Anderson", - username: "alice@entralocal.dev", - subject: "alice-subject", - }, -}); - -const signedOut = authValue({ isAuthenticated: false, user: null }); - const meta = { component: UserMenu, tags: ["ai-generated"], @@ -41,7 +18,7 @@ type Story = StoryObj; export const SignedIn: Story = { decorators: [ (Story) => ( - + ), @@ -62,7 +39,7 @@ export const SignedIn: Story = { export const SignedOut: Story = { decorators: [ (Story) => ( - + ), diff --git a/apps/portal/src/components/auth/RequireAuth.stories.tsx b/apps/portal/src/components/auth/RequireAuth.stories.tsx new file mode 100644 index 000000000..d8617ab4f --- /dev/null +++ b/apps/portal/src/components/auth/RequireAuth.stories.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { ComponentType } from "react"; +import { expect } from "storybook/test"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { signedOutAuth } from "@/contexts/authFixtures"; +import { ApiError } from "@/lib/api"; +import { RequireAuth } from "./RequireAuth"; + +function withAuth(auth: Partial) { + return (Story: ComponentType) => ( + + + + ); +} + +const meta = { + component: RequireAuth, + tags: ["ai-generated"], + args: { children:
Scope application
}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Resolving: Story = { + decorators: [withAuth({ status: "resolving" })], + play: async ({ canvas }) => { + await expect(canvas.getByText("Connecting to Scope…")).toBeVisible(); + }, +}; + +export const NotEnrolled: Story = { + decorators: [withAuth({ status: "denied", error: new ApiError("Not enrolled", 403, "user_not_enrolled") })], + play: async ({ canvas }) => { + await expect(canvas.getByRole("button", { name: "Log in" })).toBeVisible(); + }, +}; + +export const Disabled: Story = { + decorators: [withAuth({ status: "denied", error: new ApiError("Disabled", 403, "user_disabled") })], + play: async ({ canvas }) => { + await expect(canvas.getByText("Account disabled")).toBeVisible(); + await expect(canvas.queryByRole("button", { name: "Retry" })).toBeNull(); + }, +}; + +export const Unavailable: Story = { + decorators: [withAuth({ status: "error", error: new ApiError("Unavailable", 503) })], + play: async ({ canvas }) => { + await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible(); + }, +}; diff --git a/apps/portal/src/components/auth/RequireAuth.tsx b/apps/portal/src/components/auth/RequireAuth.tsx index 4ff07320a..160413be2 100644 --- a/apps/portal/src/components/auth/RequireAuth.tsx +++ b/apps/portal/src/components/auth/RequireAuth.tsx @@ -2,24 +2,21 @@ // Licensed under the MIT License. /** - * Route guard that gates the app behind Microsoft Entra ID sign-in. + * Gates all app providers and routes until Scope accepts the MSAL account. * * Signed-out users are NOT auto-redirected to the IdP. Instead we render a * minimal placeholder page (a real landing page will replace it later) with a * "Log in" button; the interactive redirect only starts when the user clicks it. - * Authentication only — no roles/permissions are checked here (see - * docs/architecture/auth-rbac.md §8, subtask 10). * * When the Portal auth config is missing (a production build without the * `VITE_AUTH_*` env vars), we render a clear configuration error instead of * bouncing into a broken redirect loop. */ import type { ReactNode } from "react"; -import { InteractionStatus } from "@azure/msal-browser"; -import { useMsal, useIsAuthenticated } from "@azure/msal-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/contexts/AuthContext"; import { isAuthConfigured, isAuthEnabled } from "@/lib/auth/msalInstance"; +import { ApiError } from "@/lib/api"; function AuthPending({ label }: { label: string }) { return ( @@ -67,8 +64,7 @@ function NotConfigured() { } export function RequireAuth({ children }: { children: ReactNode }) { - const isAuthenticated = useIsAuthenticated(); - const { inProgress } = useMsal(); + const { status, error, isReady, isAuthenticated, login, logout, retry } = useAuth(); // Auth feature disabled → no gate at all; render the app as-is. if (!isAuthEnabled) { @@ -79,17 +75,39 @@ export function RequireAuth({ children }: { children: ReactNode }) { return ; } - if (isAuthenticated) { + if (isReady && isAuthenticated) { return <>{children}; } - // A redirect sign-in (or the initial redirect-handshake on load) is settling — - // show a spinner rather than flashing the landing page. - if ( - inProgress !== InteractionStatus.None && - inProgress !== InteractionStatus.Logout - ) { - return ; + if (status === "resolving") { + return ; + } + + if (status === "denied" || status === "error") { + const code = error instanceof ApiError ? error.code : undefined; + const notEnrolled = code === "user_not_enrolled"; + const disabled = code === "user_disabled"; + return ( +
+
+

+ {notEnrolled ? "Sign in to join Scope" : disabled ? "Account disabled" + : status === "denied" ? "Access denied" : "Unable to connect to Scope"} +

+

+ {notEnrolled ? "This account is not enrolled. Log in explicitly to create your Scope profile." + : disabled ? "Your Scope account is disabled. Contact an administrator." + : status === "denied" ? "Your account does not have access to Scope." + : "Scope could not verify your account. Please try again."} +

+
+ {notEnrolled && } + {status === "error" && } + +
+
+
+ ); } return ; diff --git a/apps/portal/src/contexts/AuthContext.test.tsx b/apps/portal/src/contexts/AuthContext.test.tsx new file mode 100644 index 000000000..4427c911b --- /dev/null +++ b/apps/portal/src/contexts/AuthContext.test.tsx @@ -0,0 +1,477 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { StrictMode } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider, focusManager, useQuery } from "@tanstack/react-query"; +import type { AccountInfo } from "@azure/msal-browser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AuthProvider, useAuth, type AuthContextValue } from "./AuthContext"; +import { FeatureFlagProvider } from "./FeatureFlagContext"; +import { RequireAuth } from "@/components/auth/RequireAuth"; +import { useFavicon } from "@/hooks/useFavicon"; +import { api } from "@/lib/api"; +import { + resetApiClient, setApiTokenProvider, setReauthHandler, + type TokenProvider, type ReauthHandler, +} from "@/lib/api-client"; +import { getAccountKey } from "@/lib/auth/msalInstance"; + +const msal = vi.hoisted(() => ({ + account: null as AccountInfo | null, + inProgress: "none", + enabled: true, + pending: undefined as { accountKey: string } | undefined, + login: vi.fn(), + logout: vi.fn(), + consume: vi.fn(), + discard: vi.fn(), +})); + +vi.mock("@azure/msal-react", () => ({ + useAccount: () => msal.account, + useMsal: () => ({ + instance: { getActiveAccount: () => msal.account }, + accounts: msal.account ? [msal.account] : [], + inProgress: msal.inProgress, + }), +})); + +vi.mock("@/lib/auth/msalInstance", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + get isAuthEnabled() { return msal.enabled; }, + isAuthConfigured: true, + login: msal.login, + logout: msal.logout, + getPendingRedirectLogin: (account: AccountInfo) => + msal.pending?.accountKey === actual.getAccountKey(account) ? msal.pending : undefined, + consumeRedirectLogin: (event: { accountKey: string }) => { + msal.consume(event); + if (msal.pending === event) msal.pending = undefined; + }, + discardRedirectLogin: (key: string) => { + msal.discard(key); + if (msal.pending?.accountKey === key) msal.pending = undefined; + }, + }; +}); + +const alice: AccountInfo = { + homeAccountId: "home", + localAccountId: "alice-subject", + tenantId: "tenant", + environment: "login.example.test", + username: "alice@example.test", + name: "IdP Alice", + idTokenClaims: { roles: ["idp-admin"] }, +}; +const scopeAlice = { + id: "11111111-2222-4333-8444-555555555555", + role: "user", + displayName: "Scope Alice", + email: "scope-alice@example.test", +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function response(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function pathOf(request: Request): string { + const url = new URL(request.url); + return url.pathname + url.search; +} + +let auth: AuthContextValue; +function AuthProbe() { + auth = useAuth(); + return {auth.status}; +} + +function ApplicationQueries() { + useFavicon(); + useQuery({ queryKey: ["projects"], queryFn: () => api.listProjects() }); + return
Application content
; +} + +describe("Scope authentication handshake", () => { + let client: QueryClient; + let fetchMock: ReturnType; + let token: ReturnType>; + let reauth: ReturnType>; + + beforeEach(() => { + vi.clearAllMocks(); + resetApiClient(); + msal.account = alice; + msal.inProgress = "none"; + msal.enabled = true; + msal.pending = undefined; + client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + fetchMock = vi.fn(async (request: Request) => response( + pathOf(request).startsWith("/api/v1/users/me") ? scopeAlice : [], + )); + vi.stubGlobal("fetch", fetchMock); + token = vi.fn().mockResolvedValue("idp-access-token"); + reauth = vi.fn(); + setApiTokenProvider(token); + setReauthHandler(reauth); + }); + + afterEach(async () => { + cleanup(); + await act(async () => {}); + client.clear(); + focusManager.setFocused(undefined); + resetApiClient(); + vi.unstubAllGlobals(); + }); + + function freshLogin() { + msal.pending = { accountKey: getAccountKey(alice) }; + } + + function tree() { + return ( + + + + + {/* Keep this eager provider outside the guard to test its own gate. */} + + + + + + + + + ); + } + + function requests() { + return fetchMock.mock.calls.map(([request]) => pathOf(request)); + } + + function requestMethods() { + return fetchMock.mock.calls.map(([request]) => (request as Request).method); + } + + it("sends exactly the enrollment POST first; flags, favicon and pages wait for its response", async () => { + freshLogin(); + const lookup = deferred(); + fetchMock.mockImplementationOnce(() => lookup.promise); + const view = render(tree()); + + await waitFor(() => expect(requests()).toEqual(["/api/v1/users/me"])); + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.method).toBe("POST"); + expect(request.headers.get("authorization")).toBe("Bearer idp-access-token"); + expect(auth.isReady).toBe(false); + expect(auth.isAuthenticated).toBe(false); + expect(auth.user).toBeNull(); + expect(screen.queryByText("Application content")).toBeNull(); + + view.rerender(tree()); + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + expect(requests()).toEqual(["/api/v1/users/me"]); + expect(msal.consume).not.toHaveBeenCalled(); + + await act(async () => lookup.resolve(response(scopeAlice))); + await waitFor(() => expect(requests()).toEqual(expect.arrayContaining([ + "/api/v1/feature-flags", "/api/v1/version", "/api/v1/projects", + ]))); + expect(auth.user).toMatchObject({ + ...scopeAlice, name: "Scope Alice", username: alice.username, subject: alice.localAccountId, + }); + expect(auth.isAuthenticated).toBe(true); + expect(auth.isReady).toBe(true); + expect(msal.consume).toHaveBeenCalledTimes(1); + expect(msal.pending).toBeUndefined(); + + view.rerender(tree()); + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + expect(requests().filter((path) => path.includes("/users/me"))).toHaveLength(1); + }); + + it("uses a plain lookup on cached-account reload and keeps IdP display fallbacks", async () => { + fetchMock.mockResolvedValueOnce(response({ id: scopeAlice.id, role: "admin" })); + render(tree()); + await waitFor(() => expect(auth.isReady).toBe(true)); + expect(requests()[0]).toBe("/api/v1/users/me"); + expect(requestMethods()[0]).toBe("GET"); + expect(auth.user).toEqual({ + id: scopeAlice.id, role: "admin", name: alice.name, + username: alice.username, subject: alice.localAccountId, + }); + expect(msal.consume).not.toHaveBeenCalled(); + }); + + it("does not start any Scope queries while signed out or MSAL is still resolving", async () => { + msal.account = null; + const view = render(tree()); + await act(async () => {}); + expect(fetchMock).not.toHaveBeenCalled(); + expect(auth.status).toBe("signed-out"); + expect(auth.isReady).toBe(false); + expect(screen.getByRole("button", { name: "Log in" })).toBeTruthy(); + + msal.account = alice; + msal.inProgress = "handleRedirect"; + view.rerender(tree()); + await act(async () => {}); + expect(auth.status).toBe("resolving"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("preserves anonymous app queries when auth is disabled without fabricating a user", async () => { + msal.account = null; + msal.enabled = false; + render(tree()); + await waitFor(() => expect(requests()).toContain("/api/v1/feature-flags")); + expect(requests()).toContain("/api/v1/projects"); + expect(requests()).toContain("/api/v1/version"); + expect(requests().some((path) => path.includes("/users/me"))).toBe(false); + expect(auth.user).toBeNull(); + expect(auth.isReady).toBe(true); + }); + + it("requires explicit login for an unenrolled restored account, never automatically enrolling or redirecting", async () => { + fetchMock.mockResolvedValueOnce(response({ error: "Not enrolled", code: "user_not_enrolled" }, 403)); + const view = render(tree()); + await screen.findByText("Sign in to join Scope"); + expect(auth.status).toBe("denied"); + expect(auth.error).toMatchObject({ status: 403, code: "user_not_enrolled", message: "Not enrolled" }); + expect(auth.user).toBeNull(); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + view.rerender(tree()); + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + }); + expect(requests()).toEqual(["/api/v1/users/me"]); + expect(reauth).not.toHaveBeenCalled(); + expect(msal.login).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Log in" })); + expect(msal.login).toHaveBeenCalledTimes(1); + expect(requests()).toEqual(["/api/v1/users/me"]); + }); + + it("shows disabled-account denial without an automatic retry or reauthentication loop", async () => { + freshLogin(); + fetchMock.mockResolvedValueOnce(response({ error: "Disabled", code: "user_disabled" }, 403)); + render(tree()); + await screen.findByText("Account disabled"); + expect(auth.status).toBe("denied"); + expect(auth.isAuthenticated).toBe(false); + expect(auth.isReady).toBe(false); + expect(screen.queryByRole("button", { name: "Retry" })).toBeNull(); + expect(requests()).toEqual(["/api/v1/users/me"]); + expect(requestMethods()).toEqual(["POST"]); + expect(msal.consume).not.toHaveBeenCalled(); + expect(reauth).not.toHaveBeenCalled(); + expect(token).toHaveBeenCalledTimes(1); + }); + + it.each([true, false])( + "retries a 503 explicitly with the original method (fresh login=%s) and deduplicates clicks", + async (fresh) => { + if (fresh) freshLogin(); + const path = "/api/v1/users/me"; + const method = fresh ? "POST" : "GET"; + fetchMock.mockResolvedValueOnce(response({ error: "Database unavailable" }, 503)); + const retryResult = deferred(); + fetchMock.mockImplementationOnce(() => retryResult.promise); + render(tree()); + await screen.findByText("Unable to connect to Scope"); + expect(auth.status).toBe("error"); + expect(auth.error).toMatchObject({ status: 503 }); + expect(requests()).toEqual([path]); + expect(requestMethods()).toEqual([method]); + expect(reauth).not.toHaveBeenCalled(); + expect(msal.consume).not.toHaveBeenCalled(); + + act(() => { auth.retry(); auth.retry(); }); + await waitFor(() => expect(requests()).toEqual([path, path])); + expect(requestMethods()).toEqual([method, method]); + await act(async () => retryResult.resolve(response(scopeAlice))); + await waitFor(() => expect(auth.isReady).toBe(true)); + expect(msal.consume).toHaveBeenCalledTimes(fresh ? 1 : 0); + }, + ); + + it("preserves the existing single 401 refresh retry before interactive reauthentication", async () => { + freshLogin(); + fetchMock.mockImplementation(async () => response({ error: "Invalid JWT" }, 401)); + render(tree()); + await screen.findByText("Unable to connect to Scope"); + expect(requests()).toEqual(["/api/v1/users/me", "/api/v1/users/me"]); + expect(requestMethods()).toEqual(["POST", "POST"]); + expect(token).toHaveBeenNthCalledWith(2, { forceRefresh: true }); + expect(reauth).toHaveBeenCalledTimes(1); + expect(msal.consume).not.toHaveBeenCalled(); + expect(auth.isAuthenticated).toBe(false); + }); + + it("does not automatically replay an enrollment write after a network failure", async () => { + freshLogin(); + fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); + render(tree()); + await screen.findByText("Unable to connect to Scope"); + expect(requests()).toEqual(["/api/v1/users/me"]); + expect(requestMethods()).toEqual(["POST"]); + expect(msal.pending).toBeDefined(); + expect(msal.consume).not.toHaveBeenCalled(); + expect(reauth).not.toHaveBeenCalled(); + }); + + it.each([ + null, + {}, + { id: "idp-subject", role: "user" }, + { id: "00000000-0000-0000-0000-000000000000" }, + { id: scopeAlice.id, role: ["admin"] }, + { id: scopeAlice.id, displayName: 123 }, + ])("rejects malformed successful user responses: %j", async (body) => { + freshLogin(); + fetchMock.mockResolvedValueOnce(response(body)); + render(tree()); + await screen.findByText("Unable to connect to Scope"); + expect(auth.error?.message).toBe("Invalid user response from Scope"); + expect(auth.user).toBeNull(); + expect(msal.consume).not.toHaveBeenCalled(); + expect(requests()).toEqual(["/api/v1/users/me"]); + expect(requestMethods()).toEqual(["POST"]); + }); + + it("cancels a replaced account's handshake and discards its late response and cache", async () => { + freshLogin(); + const first = deferred(); + const second = deferred(); + fetchMock.mockImplementationOnce(() => first.promise).mockImplementationOnce(() => second.promise); + const view = render(tree()); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const firstRequest = fetchMock.mock.calls[0][0] as Request; + client.setQueryData(["old-user-data"], { private: true }); + + msal.account = { ...alice, localAccountId: "bob-subject", name: "Bob" }; + view.rerender(tree()); + expect(auth.user).toBeNull(); + expect(auth.isReady).toBe(false); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(firstRequest.signal.aborted).toBe(true); + expect(client.getQueryData(["old-user-data"])).toBeUndefined(); + expect(requests()).toEqual(["/api/v1/users/me", "/api/v1/users/me"]); + expect(requestMethods()).toEqual(["POST", "GET"]); + + await act(async () => first.resolve(response(scopeAlice))); + expect(auth.user).toBeNull(); + expect(msal.consume).not.toHaveBeenCalled(); + const bobId = "66666666-7777-4888-9999-aaaaaaaaaaaa"; + await act(async () => second.resolve(response({ id: bobId, role: "user" }))); + await waitFor(() => expect(auth.user?.id).toBe(bobId)); + expect(auth.user?.subject).toBe("bob-subject"); + expect(auth.user?.name).toBe("Bob"); + }); + + it("clears an accepted identity, aborts user queries and prevents late cache reuse on logout", async () => { + const projects = deferred(); + fetchMock.mockImplementation(async (request: Request) => { + if (pathOf(request) === "/api/v1/projects") return projects.promise; + return response(pathOf(request).includes("/users/me") ? scopeAlice : []); + }); + const view = render(tree()); + await waitFor(() => expect(requests()).toContain("/api/v1/projects")); + const projectRequest = fetchMock.mock.calls.find(([request]) => pathOf(request) === "/api/v1/projects")![0] as Request; + client.setQueryData(["private"], "alice"); + await act(async () => auth.logout()); + expect(projectRequest.signal.aborted).toBe(true); + expect(auth.user).toBeNull(); + expect(auth.account).toBeNull(); + expect(auth.isAuthenticated).toBe(false); + expect(client.getQueryData(["private"])).toBeUndefined(); + expect(msal.logout).toHaveBeenCalledTimes(1); + await act(async () => projects.resolve(response([{ id: "alice-project" }]))); + view.rerender(tree()); + expect(client.getQueryData(["projects"])).toBeUndefined(); + expect(auth.status).toBe("signed-out"); + expect(requests().filter((path) => path.includes("/users/me"))).toHaveLength(1); + }); + + it("clears ready-account data and aborts its queries before admitting a switched account", async () => { + const projects = deferred(); + const nextLookup = deferred(); + fetchMock.mockImplementation(async (request: Request) => { + if (pathOf(request) === "/api/v1/projects") return projects.promise; + return response(pathOf(request).includes("/users/me") ? scopeAlice : []); + }); + const view = render(tree()); + await waitFor(() => expect(requests()).toContain("/api/v1/projects")); + const oldRequest = fetchMock.mock.calls.find(([request]) => pathOf(request) === "/api/v1/projects")![0] as Request; + client.setQueryData(["private"], "alice"); + fetchMock.mockImplementationOnce(() => nextLookup.promise); + msal.account = { ...alice, tenantId: "other-tenant", localAccountId: "bob-subject" }; + view.rerender(tree()); + expect(auth.user).toBeNull(); + expect(auth.isReady).toBe(false); + await waitFor(() => expect(oldRequest.signal.aborted).toBe(true)); + expect(client.getQueryData(["private"])).toBeUndefined(); + await act(async () => projects.resolve(response([{ id: "alice-project" }]))); + expect(client.getQueryData(["projects"])).toBeUndefined(); + expect(auth.user).toBeNull(); + + const bobId = "66666666-7777-4888-9999-aaaaaaaaaaaa"; + await act(async () => nextLookup.resolve(response({ id: bobId }))); + await waitFor(() => expect(auth.user?.id).toBe(bobId)); + expect(auth.user?.role).toBeUndefined(); + }); + + it("cancels logout during a handshake and ignores a response even if fetch ignores cancellation", async () => { + freshLogin(); + const lookup = deferred(); + fetchMock.mockImplementationOnce(() => lookup.promise); + render(tree()); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const request = fetchMock.mock.calls[0][0] as Request; + await act(async () => auth.logout()); + expect(request.signal.aborted).toBe(true); + await act(async () => lookup.resolve(response(scopeAlice))); + expect(auth.user).toBeNull(); + expect(auth.status).toBe("signed-out"); + expect(msal.pending).toBeUndefined(); + expect(msal.consume).not.toHaveBeenCalled(); + expect(requests()).toEqual(["/api/v1/users/me"]); + expect(requestMethods()).toEqual(["POST"]); + }); + + it("aborts a handshake on a real unmount, unlike StrictMode's effect replay", async () => { + const lookup = deferred(); + fetchMock.mockImplementationOnce(() => lookup.promise); + const view = render(tree()); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const request = fetchMock.mock.calls[0][0] as Request; + expect(request.signal.aborted).toBe(false); + view.unmount(); + await act(async () => {}); + expect(request.signal.aborted).toBe(true); + await act(async () => lookup.resolve(response(scopeAlice))); + expect(msal.consume).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/portal/src/contexts/AuthContext.tsx b/apps/portal/src/contexts/AuthContext.tsx index 86d10df71..f91dc618d 100644 --- a/apps/portal/src/contexts/AuthContext.tsx +++ b/apps/portal/src/contexts/AuthContext.tsx @@ -1,44 +1,43 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -/** - * Portal authentication context (Microsoft Entra ID via MSAL). - * - * Authentication only — there is no authorization here (no roles/permissions). - * Identity is derived from the **MSAL account token claims**, not from an API - * endpoint: the API does not verify tokens yet, so `GET /api/v1/users/me` is - * intentionally not called (see docs/architecture/auth-rbac.md §8, subtask 10). - * - * When roles/permissions land, this context is the place to add them (populated - * from `/users/me`) without touching call sites. - */ -import { createContext, useContext, useMemo, type ReactNode } from "react"; -import { useMsal, useIsAuthenticated } from "@azure/msal-react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { useAccount, useMsal } from "@azure/msal-react"; import type { AccountInfo } from "@azure/msal-browser"; -import { login as msalLogin, logout as msalLogout } from "@/lib/auth/msalInstance"; +import { useQueryClient } from "@tanstack/react-query"; +import { api, ApiError, type CurrentUserResponse } from "@/lib/api"; +import { setApiSessionSignal } from "@/lib/api-client"; +import { + consumeRedirectLogin, + discardRedirectLogin, + getAccountKey, + getPendingRedirectLogin, + isAuthEnabled, + login as msalLogin, + logout as msalLogout, +} from "@/lib/auth/msalInstance"; -/** The signed-in user, projected from MSAL account claims. */ -export interface AuthUser { - /** Display name (`name` claim), falling back to the username. */ +/** Scope owns the ID and role; IdP fields are display/compatibility fallbacks. */ +export interface AuthUser extends CurrentUserResponse { name: string; - /** `preferred_username` / UPN / email. */ username: string; - /** Stable IdP subject (`localAccountId`), useful as a client-side key. */ subject: string; } +export type AuthStatus = "signed-out" | "resolving" | "ready" | "denied" | "error"; + interface AuthContextValue { - /** Raw MSAL account, or `null` when signed out. */ account: AccountInfo | null; - /** Projected user identity, or `null` when signed out. */ user: AuthUser | null; + status: AuthStatus; + error: Error | null; isAuthenticated: boolean; - /** `true` until MSAL has finished any in-flight redirect handshake. */ + /** Scope has accepted this account (or auth is disabled). */ isReady: boolean; - /** Start an interactive redirect sign-in. */ login: () => Promise; - /** Sign out via redirect. */ logout: () => Promise; + /** Retry a failed lookup, preserving any unconsumed redirect login event. */ + retry: () => void; } const AuthContext = createContext(undefined); @@ -50,40 +49,137 @@ const AuthContext = createContext(undefined); export { AuthContext }; export type { AuthContextValue }; -function toUser(account: AccountInfo | null): AuthUser | null { - if (!account) return null; +function toUser(account: AccountInfo, scopeUser: CurrentUserResponse): AuthUser { const username = account.username || ""; return { - name: account.name || username || "Signed in", + ...scopeUser, + name: scopeUser.displayName || account.name || username || scopeUser.email || "Signed in", username, subject: account.localAccountId || account.homeAccountId || username, }; } +interface Session { + key: string; + controller: AbortController; + status: AuthStatus; +} + +interface Snapshot { + key: string; + status: AuthStatus; + user: AuthUser | null; + error: Error | null; +} + export interface AuthProviderProps { children: ReactNode; } export function AuthProvider({ children }: AuthProviderProps) { - const { instance, accounts, inProgress } = useMsal(); - const isAuthenticated = useIsAuthenticated(); - - const account = instance.getActiveAccount() ?? accounts[0] ?? null; - - const value = useMemo( - () => ({ - account, - user: toUser(account), - isAuthenticated, - isReady: inProgress === "none", - login: msalLogin, - logout: msalLogout, - }), - // `account` identity changes when the active account or account list does; - // depending on the id keeps the memo stable across benign re-renders. - // eslint-disable-next-line react-hooks/exhaustive-deps - [account?.homeAccountId, isAuthenticated, inProgress], - ); + const { accounts, inProgress } = useMsal(); + // useMsal alone does not rerender when only the active account changes. + const activeAccount = useAccount(); + const queryClient = useQueryClient(); + const session = useRef(null); + const mounted = useRef(false); + const [snapshot, setSnapshot] = useState(null); + const [signedOut, setSignedOut] = useState(false); + const [attempt, setAttempt] = useState(0); + const account = signedOut || inProgress === "logout" + ? null + : accounts.find((candidate) => activeAccount && getAccountKey(candidate) === getAccountKey(activeAccount)) + ?? accounts[0] ?? null; + const key = isAuthEnabled && account ? getAccountKey(account) : null; + const canResolve = inProgress === "none"; + + const clearSession = useCallback((discardLogin = true) => { + const previous = session.current; + session.current = null; + previous?.controller.abort(); + if (previous && discardLogin) discardRedirectLogin(previous.key); + setApiSessionSignal(isAuthEnabled ? AbortSignal.abort() : undefined); + void queryClient.cancelQueries(); + queryClient.clear(); + }, [queryClient]); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + // StrictMode immediately reattaches effects. Keep its in-flight request, + // but cancel on an actual unmount without allowing late results through. + queueMicrotask(() => { + if (!mounted.current) clearSession(); + }); + }; + }, [clearSession]); + + useEffect(() => { + if (!isAuthEnabled) return; + if (session.current?.key !== key) { + clearSession(); + setSnapshot(null); + } + if (!account || !key || !canResolve || session.current) return; + + const current: Session = { key, controller: new AbortController(), status: "resolving" }; + const redirectLogin = getPendingRedirectLogin(account); + session.current = current; + setApiSessionSignal(current.controller.signal); + setSnapshot({ key, status: "resolving", user: null, error: null }); + const isCurrent = () => + mounted.current && session.current === current && !current.controller.signal.aborted; + + // Deliberately outside React Query: no focus/reconnect or automatic retries + // may repeat the enrollment POST's database writes. + const resolveUser = redirectLogin ? api.enrollCurrentUser : api.getCurrentUser; + void resolveUser({ + signal: current.controller.signal, + }).then((user) => { + if (!isCurrent()) return; + if (redirectLogin) consumeRedirectLogin(redirectLogin); + current.status = "ready"; + setSnapshot({ key, status: "ready", user: toUser(account, user), error: null }); + }, (cause: unknown) => { + if (!isCurrent()) return; + const error = cause instanceof Error ? cause : new Error("Unable to connect to Scope"); + current.status = error instanceof ApiError && error.status === 403 ? "denied" : "error"; + setSnapshot({ key, status: current.status, user: null, error }); + }); + }, [account, key, canResolve, attempt, clearSession]); + + const logout = useCallback(async () => { + clearSession(); + setSignedOut(true); + setSnapshot(null); + await msalLogout(); + }, [clearSession]); + + const retry = useCallback(() => { + if (!session.current || !["denied", "error"].includes(session.current.status)) return; + clearSession(false); + setSnapshot(null); + setAttempt((value) => value + 1); + }, [clearSession]); + + // Account changes gate children during render, before cleanup effects run. + const active = key && snapshot?.key === key ? snapshot : null; + const status: AuthStatus = !isAuthEnabled ? "ready" + : active?.status ?? (account || (!signedOut && !canResolve && inProgress !== "logout") + ? "resolving" : "signed-out"); + const user = active?.user ?? null; + const value: AuthContextValue = { + account, + user, + status, + error: active?.error ?? null, + isAuthenticated: status === "ready" && user !== null, + isReady: status === "ready", + login: msalLogin, + logout, + retry, + }; return {children}; } diff --git a/apps/portal/src/contexts/FeatureFlagContext.tsx b/apps/portal/src/contexts/FeatureFlagContext.tsx index ea131e139..640248c1e 100644 --- a/apps/portal/src/contexts/FeatureFlagContext.tsx +++ b/apps/portal/src/contexts/FeatureFlagContext.tsx @@ -5,6 +5,8 @@ import { createContext, useContext, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api"; import type { FeatureFlag } from "@/types"; +import { AuthContext } from "./AuthContext"; +import { isAuthEnabled } from "@/lib/auth/msalInstance"; interface FeatureFlagContextValue { /** All feature flags from the API */ @@ -22,9 +24,12 @@ const FeatureFlagContext = createContext({ }); export function FeatureFlagProvider({ children }: { children: ReactNode }) { + const auth = useContext(AuthContext); + const enabled = !isAuthEnabled || Boolean(auth?.isReady && auth.isAuthenticated); const { data: flags = [], isLoading } = useQuery({ queryKey: ["feature-flags"], queryFn: api.listFeatureFlags, + enabled, staleTime: 30_000, // Cache flags for 30s to avoid excessive requests }); diff --git a/apps/portal/src/contexts/authFixtures.ts b/apps/portal/src/contexts/authFixtures.ts new file mode 100644 index 000000000..b81dc7e1b --- /dev/null +++ b/apps/portal/src/contexts/authFixtures.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AuthContextValue } from "./AuthContext"; + +/** Deterministic contexts for isolated stories/tests; never perform a handshake. */ +export const signedOutAuth: AuthContextValue = { + account: null, + user: null, + status: "signed-out", + error: null, + isAuthenticated: false, + isReady: false, + login: async () => {}, + logout: async () => {}, + retry: () => {}, +}; + +export const signedInAuth: AuthContextValue = { + ...signedOutAuth, + status: "ready", + isAuthenticated: true, + isReady: true, + account: { + homeAccountId: "alice-home", + localAccountId: "alice-subject", + tenantId: "demo-tenant", + environment: "login.example.test", + username: "alice@entralocal.dev", + name: "Alice Anderson", + }, + user: { + id: "11111111-2222-4333-8444-555555555555", + role: "user", + name: "Alice Anderson", + displayName: "Alice Anderson", + username: "alice@entralocal.dev", + subject: "alice-subject", + }, +}; diff --git a/apps/portal/src/lib/api-client.test.ts b/apps/portal/src/lib/api-client.test.ts index c7d7f64b8..363994ac4 100644 --- a/apps/portal/src/lib/api-client.test.ts +++ b/apps/portal/src/lib/api-client.test.ts @@ -7,6 +7,7 @@ import { resetApiClient, setApiTokenProvider, setReauthHandler, + setApiSessionSignal, } from "./api-client"; /** Read the Authorization header off whatever ky passed to `fetch`. */ @@ -97,4 +98,47 @@ describe("portal api-client", () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(reauth).toHaveBeenCalledTimes(1); }); + + it.each([403, 503])("does not retry or redirect on %s", async (status) => { + const provider = vi.fn(() => "idp-token"); + const reauth = vi.fn(); + setApiTokenProvider(provider); + setReauthHandler(reauth); + fetchMock.mockResolvedValue(new Response("{}", { status })); + expect((await apiClient("https://scope.test/api/v1/users/me")).status).toBe(status); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(provider).toHaveBeenCalledTimes(1); + expect(reauth).not.toHaveBeenCalled(); + }); + + it("does not send an old account's request if it is cancelled during token acquisition", async () => { + const controller = new AbortController(); + let resolve!: (token: string) => void; + const token = new Promise((done) => { resolve = done; }); + const provider = vi.fn(() => token); + setApiTokenProvider(provider); + setApiSessionSignal(controller.signal); + const result = apiClient("https://scope.test/api/v1/users/me"); + const rejection = expect(result).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(provider).toHaveBeenCalledTimes(1)); + controller.abort(); + resolve("old-account-token"); + await rejection; + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("preserves POST bodies through the session signal and the existing 401 retry", async () => { + setApiSessionSignal(new AbortController().signal); + setApiTokenProvider(() => "idp-token"); + const bodies: string[] = []; + fetchMock.mockImplementation(async (request: Request) => { + bodies.push(await request.clone().text()); + return new Response("{}", { status: bodies.length === 1 ? 401 : 200 }); + }); + const result = await apiClient.post("https://scope.test/api/v1/projects", { + json: { name: "My project" }, + }); + expect(result.status).toBe(200); + expect(bodies).toEqual(['{"name":"My project"}', '{"name":"My project"}']); + }); }); diff --git a/apps/portal/src/lib/api-client.ts b/apps/portal/src/lib/api-client.ts index 41f17064e..ad4b6e1c1 100644 --- a/apps/portal/src/lib/api-client.ts +++ b/apps/portal/src/lib/api-client.ts @@ -65,6 +65,12 @@ const noopReauthHandler: ReauthHandler = () => {}; // bootstrap via wireApiAuth(); until then requests go out unauthenticated. let tokenProvider: TokenProvider = noopTokenProvider; let reauthHandler: ReauthHandler = noopReauthHandler; +let sessionSignal: AbortSignal | undefined; + +/** Cancel every account-bound request, even query functions without a signal. */ +export function setApiSessionSignal(signal: AbortSignal | undefined): void { + sessionSignal = signal; +} /** Override how bearer tokens are resolved (wired by Portal auth). */ export function setApiTokenProvider(provider: TokenProvider): void { @@ -80,19 +86,29 @@ export function setReauthHandler(handler: ReauthHandler): void { export function resetApiClient(): void { tokenProvider = noopTokenProvider; reauthHandler = noopReauthHandler; + sessionSignal = undefined; } const authHook: BeforeRequestHook = async ({ request }) => { - if (request.headers.has("authorization")) return; - const token = await tokenProvider(); - if (token) request.headers.set("authorization", `Bearer ${token}`); + const signal = sessionSignal + ? AbortSignal.any([request.signal, sessionSignal]) + : request.signal; + signal.throwIfAborted(); + if (!request.headers.has("authorization")) { + const token = await tokenProvider(); + if (token) request.headers.set("authorization", `Bearer ${token}`); + } + signal.throwIfAborted(); + return new Request(request, { signal }); }; // On a 401, force a fresh token so ky retries the request with it. ky owns // request/body reconstruction for the retry, so this is safe for POSTs. The // mutated request is returned so ky retries with the refreshed header. const reauthRetryHook: BeforeRetryHook = async ({ request }) => { + request.signal.throwIfAborted(); const token = await tokenProvider({ forceRefresh: true }); + request.signal.throwIfAborted(); if (token) request.headers.set("authorization", `Bearer ${token}`); return request; }; @@ -105,7 +121,8 @@ const reauthRetryHook: BeforeRetryHook = async ({ request }) => { // force-refreshes the token before the second attempt. // - 401 after the retry (retryCount > 0): give up and hand off to the // interactive re-auth handler (redirect), returning the 401 to the caller. -const handle401Hook: AfterResponseHook = async ({ response, retryCount }) => { +const handle401Hook: AfterResponseHook = async ({ request, response, retryCount }) => { + request.signal.throwIfAborted(); if (response.status !== 401) return response; if (retryCount === 0) { return ky.retry({ delay: 0 }); @@ -127,6 +144,9 @@ export const apiClient: KyInstance = ky.create({ // cockatiel `withRetry`/`@Retry`, so the two layers never compound. retry: { limit: 1, + // Forced ky.retry() on 401 bypasses this. Network failures must not replay + // an enrollment POST whose database write may already have succeeded. + shouldRetry: () => false, }, hooks: { beforeRequest: [authHook], diff --git a/apps/portal/src/lib/api-current-user.test.ts b/apps/portal/src/lib/api-current-user.test.ts new file mode 100644 index 000000000..fd1f0d753 --- /dev/null +++ b/apps/portal/src/lib/api-current-user.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api, ApiError } from "./api"; +import { apiClient } from "./api-client"; + +vi.mock("./api-client", () => ({ apiClient: vi.fn() })); + +const user = { id: "11111111-2222-4333-8444-555555555555", role: "user" }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(apiClient).mockResolvedValue(new Response(JSON.stringify(user))); +}); + +describe("current user API", () => { + it.each([ + ["getCurrentUser", "GET"], + ["enrollCurrentUser", "POST"], + ] as const)("sends %s as an uncached %s request with caller cancellation", async (operation, method) => { + const controller = new AbortController(); + expect(await api[operation]({ signal: controller.signal })).toEqual(user); + expect(apiClient).toHaveBeenCalledWith("/api/v1/users/me", { + method, + headers: { "Content-Type": "application/json" }, + cache: "no-store", + signal: controller.signal, + }); + }); + + it("keeps HTTP status and code without changing existing error messages", async () => { + vi.mocked(apiClient).mockResolvedValue(new Response(JSON.stringify({ + error: "Not enrolled", code: "user_not_enrolled", + }), { status: 403 })); + await expect(api.getCurrentUser()).rejects.toMatchObject({ + name: "ApiError", message: "Not enrolled", status: 403, code: "user_not_enrolled", + }); + }); + + it("preserves existing validation detail formatting", async () => { + vi.mocked(apiClient).mockResolvedValue(new Response(JSON.stringify({ + error: "Invalid request", details: [{ path: "login", message: "Invalid value" }], + }), { status: 400 })); + await expect(api.getCurrentUser()).rejects.toEqual( + new ApiError("Invalid request: login: Invalid value", 400), + ); + }); + + it("retains HTTP fallback messages for non-JSON errors", async () => { + vi.mocked(apiClient).mockResolvedValue(new Response("", { status: 503 })); + await expect(api.getCurrentUser()).rejects.toMatchObject({ + message: "HTTP 503", status: 503, + }); + }); +}); diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index 912581db5..e6985d01b 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -12,6 +12,38 @@ import { MAX_ARCHIVE_UPLOAD_LABEL } from "./codebaseUpload"; const BASE = "/api/v1"; +/** Browser-safe response from the Scope identity endpoint (not IdP claims). */ +export interface CurrentUserResponse { + id: string; + role?: string; + email?: string; + displayName?: string; + idp?: string; + idpTenant?: string; +} + +export class ApiError extends Error { + constructor(message: string, public readonly status: number, public readonly code?: string) { + super(message); + this.name = "ApiError"; + } +} + +function validateCurrentUser(value: unknown): CurrentUserResponse { + if ( + !value || typeof value !== "object" || + !("id" in value) || typeof value.id !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.id) || + value.id === "00000000-0000-0000-0000-000000000000" || + ["role", "email", "displayName", "idp", "idpTenant"].some( + (key) => key in value && typeof (value as Record)[key] !== "string", + ) + ) { + throw new Error("Invalid user response from Scope"); + } + return value as CurrentUserResponse; +} + /** * Append `projectId=` to an already-built request path, choosing `?` vs `&` * based on whether the path already carries a query string. Mirrors the CLI @@ -102,19 +134,37 @@ async function request(path: string, init?: RequestInit, opts?: RequestOpts): // relative-time displays survive a misconfigured local clock. recordServerDate(res.headers.get("Date")); if (!res.ok) { - const body = await res.json().catch(() => ({ error: res.statusText })) as { error?: string; details?: Array<{ path: string; message: string }> }; + const body = await res.json().catch(() => ({ error: res.statusText })) as { error?: string; code?: string; details?: Array<{ path: string; message: string }> }; const message = body.error || `HTTP ${res.status}`; const details = body.details; if (details?.length) { - throw new Error(`${message}: ${details.map((d) => `${d.path || "body"}: ${d.message}`).join(", ")}`); + throw new ApiError(`${message}: ${details.map((d) => `${d.path || "body"}: ${d.message}`).join(", ")}`, res.status, body.code); } - throw new Error(message); + throw new ApiError(message, res.status, body.code); } if (res.status === 204) return undefined as T; return res.json(); } +async function requestCurrentUser( + method: "GET" | "POST", + opts: { signal?: AbortSignal } = {}, +): Promise { + const user = await request("/users/me", { + method, + cache: "no-store", + signal: opts.signal, + }); + return validateCurrentUser(user); +} + export const api = { + /** Only AuthProvider calls these; enrollment must never be prefetched. */ + getCurrentUser: (opts: { signal?: AbortSignal } = {}): Promise => + requestCurrentUser("GET", opts), + enrollCurrentUser: (opts: { signal?: AbortSignal } = {}): Promise => + requestCurrentUser("POST", opts), + /** List runs with cursor-based pagination, server-side filtering and sorting */ listRuns: (opts?: RunFilterParams & { sortBy?: RunSortField; sortDir?: RunSortDir; limit?: number; after?: string; before?: string; last?: boolean }): Promise> => { return request(`/requests${qs({ diff --git a/apps/portal/src/lib/auth/authConfig.test.ts b/apps/portal/src/lib/auth/authConfig.test.ts index 97016ae88..0145944d5 100644 --- a/apps/portal/src/lib/auth/authConfig.test.ts +++ b/apps/portal/src/lib/auth/authConfig.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveAuthEnabled } from "./authConfig"; // The auth feature is ON by default (secure by default) with three independent @@ -25,6 +25,74 @@ describe("resolveAuthEnabled", () => { expect(resolveAuthEnabled(undefined, env({}), false)).toBe(true); }); + describe("build-time authentication settings", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + async function config(overrides: Record = {}) { + vi.resetModules(); + vi.stubGlobal("window", { location: { origin: "https://scope.example.com" } }); + vi.stubEnv("DEV", false); + for (const [key, value] of Object.entries({ + VITE_AUTH_CLIENT_ID: "portal-client", + VITE_AUTH_AUTHORITY: "https://login.microsoftonline.com/test-tenant", + VITE_AUTH_KNOWN_AUTHORITIES: "", + VITE_AUTH_SCOPES: "api://api-client/access_as_user", + VITE_AUTH_PROTOCOL_MODE: "", + VITE_AUTH_REDIRECT_URI: "", + VITE_AUTH_POST_LOGOUT_REDIRECT_URI: "", + VITE_AUTH_CACHE_LOCATION: "", + ...overrides, + })) { + vi.stubEnv(key, value); + } + return (await import("./authConfig")).authConfig; + } + + it("preserves production defaults when optional Docker build args are empty", async () => { + expect(await config()).toMatchObject({ + clientId: "portal-client", + authority: "https://login.microsoftonline.com/test-tenant", + scopes: ["api://api-client/access_as_user"], + knownAuthorities: [], + protocolMode: "AAD", + redirectUri: "https://scope.example.com", + postLogoutRedirectUri: "https://scope.example.com", + cacheLocation: "localStorage", + enabled: true, + isConfigured: true, + }); + }); + + it("uses explicit optional build settings", async () => { + expect(await config({ + VITE_AUTH_KNOWN_AUTHORITIES: "login.example.com, other.example.com", + VITE_AUTH_SCOPES: "api://api-client/read, api://api-client/write", + VITE_AUTH_PROTOCOL_MODE: "OIDC", + VITE_AUTH_REDIRECT_URI: "https://scope.example.com/callback", + VITE_AUTH_POST_LOGOUT_REDIRECT_URI: "https://scope.example.com/signed-out", + VITE_AUTH_CACHE_LOCATION: "sessionStorage", + })).toMatchObject({ + knownAuthorities: ["login.example.com", "other.example.com"], + scopes: ["api://api-client/read", "api://api-client/write"], + protocolMode: "OIDC", + redirectUri: "https://scope.example.com/callback", + postLogoutRedirectUri: "https://scope.example.com/signed-out", + cacheLocation: "sessionStorage", + }); + }); + + it.each(["VITE_AUTH_CLIENT_ID", "VITE_AUTH_AUTHORITY"])( + "does not silently use the emulator when production %s is missing", + async (key) => { + expect((await config({ [key]: "" })).isConfigured).toBe(false); + }, + ); + }); + it("runtime authEnabled governs integration/production (built bundle)", () => { expect( resolveAuthEnabled(runtime({ authEnabled: false }), env({}), false), diff --git a/apps/portal/src/lib/auth/authConfig.ts b/apps/portal/src/lib/auth/authConfig.ts index c5f54f192..149d6e8c0 100644 --- a/apps/portal/src/lib/auth/authConfig.ts +++ b/apps/portal/src/lib/auth/authConfig.ts @@ -83,11 +83,11 @@ const ENTRA_LOCAL_DEFAULTS = { /** Custom (non-Microsoft) authority host must be allow-listed for MSAL. */ knownAuthorities: ["localhost:8443"], /** - * Fully-qualified scope for the seeded SPA's exposed `access_as_user` scope. - * MSAL needs the resource-qualified form (`api:///`) to resolve - * the access token's audience. + * Fully-qualified scope for the seeded API app's exposed `access_as_user` + * scope. MSAL needs the resource-qualified form (`api:///`) to + * resolve the API access token's audience. */ - scopes: ["api://cccccccc-0000-0000-0000-000000000001/access_as_user"], + scopes: ["api://cccccccc-0000-0000-0000-000000000005/access_as_user"], /** entra-local speaks generic OIDC, not the AAD-specific protocol. */ protocolMode: "OIDC" as ProtocolMode, }; @@ -176,20 +176,20 @@ function resolveConfig(): PortalAuthConfig { envList(env.VITE_AUTH_SCOPES as string | undefined) ?? (isDev ? ENTRA_LOCAL_DEFAULTS.scopes : []); const protocolMode = - (env.VITE_AUTH_PROTOCOL_MODE as ProtocolMode | undefined) ?? + (env.VITE_AUTH_PROTOCOL_MODE as ProtocolMode | undefined) || (isDev ? ENTRA_LOCAL_DEFAULTS.protocolMode : "AAD"); const origin = typeof window !== "undefined" ? window.location.origin : ""; const redirectUri = - (env.VITE_AUTH_REDIRECT_URI as string | undefined) ?? origin; + (env.VITE_AUTH_REDIRECT_URI as string | undefined) || origin; const postLogoutRedirectUri = - (env.VITE_AUTH_POST_LOGOUT_REDIRECT_URI as string | undefined) ?? origin; + (env.VITE_AUTH_POST_LOGOUT_REDIRECT_URI as string | undefined) || origin; const cacheLocation = (env.VITE_AUTH_CACHE_LOCATION as | "localStorage" | "sessionStorage" - | undefined) ?? "localStorage"; + | undefined) || "localStorage"; return { clientId, diff --git a/apps/portal/src/lib/auth/msalInstance.test.ts b/apps/portal/src/lib/auth/msalInstance.test.ts new file mode 100644 index 000000000..684ebe322 --- /dev/null +++ b/apps/portal/src/lib/auth/msalInstance.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import type { AccountInfo } from "@azure/msal-browser"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fake = vi.hoisted(() => ({ + enabled: true, + account: null as AccountInfo | null, + result: null as { account: AccountInfo } | null, + initialize: vi.fn(), + handleRedirectPromise: vi.fn(), + acquireTokenSilent: vi.fn(), + logoutRedirect: vi.fn(), + clearCache: vi.fn(), +})); + +vi.mock("./authConfig", () => ({ + get isAuthEnabled() { return fake.enabled; }, + authConfig: { isConfigured: true }, + apiTokenRequestScopes: ["scope-api"], + loginRequestScopes: ["openid", "scope-api"], + buildMsalConfiguration: () => ({}), +})); + +vi.mock("@azure/msal-browser", async (importOriginal) => ({ + ...await importOriginal(), + PublicClientApplication: class { + initialize = fake.initialize; + handleRedirectPromise = fake.handleRedirectPromise; + getActiveAccount = () => fake.account; + getAllAccounts = () => fake.account ? [fake.account] : []; + setActiveAccount = (account: AccountInfo) => { fake.account = account; }; + acquireTokenSilent = fake.acquireTokenSilent; + logoutRedirect = fake.logoutRedirect; + clearCache = fake.clearCache; + }, +})); + +const account: AccountInfo = { + homeAccountId: "home", localAccountId: "subject", tenantId: "tenant", + environment: "login.example.test", username: "alice@example.test", +}; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + fake.enabled = true; + fake.account = null; + fake.result = null; + fake.handleRedirectPromise.mockImplementation(async () => fake.result); + fake.acquireTokenSilent.mockResolvedValue({ account, accessToken: "idp-token" }); +}); + +describe("redirect login outcome", () => { + it("retains one account-bound result until consumed, despite repeated initialization", async () => { + fake.result = { account }; + const auth = await import("./msalInstance"); + await Promise.all([auth.initializeAuth(), auth.initializeAuth()]); + const event = auth.getPendingRedirectLogin(account); + expect(event).toEqual({ accountKey: auth.getAccountKey(account) }); + expect(fake.account).toBe(account); + expect(fake.handleRedirectPromise).toHaveBeenCalledTimes(1); + expect(auth.getPendingRedirectLogin({ ...account, tenantId: "other-tenant" })).toBeUndefined(); + expect(auth.getPendingRedirectLogin({ ...account, localAccountId: "other-subject" })).toBeUndefined(); + + await auth.initializeAuth(); + expect(auth.getPendingRedirectLogin(account)).toBe(event); + auth.consumeRedirectLogin({ accountKey: event!.accountKey }); + expect(auth.getPendingRedirectLogin(account)).toBe(event); + auth.consumeRedirectLogin(event!); + await auth.initializeAuth(); + expect(auth.getPendingRedirectLogin(account)).toBeUndefined(); + }); + + it("does not mark a restored account or silent/forced token refresh as a login", async () => { + fake.account = account; + const auth = await import("./msalInstance"); + await auth.initializeAuth(); + expect(auth.getPendingRedirectLogin(account)).toBeUndefined(); + expect(await auth.acquireApiToken()).toBe("idp-token"); + expect(await auth.acquireApiToken({ forceRefresh: true })).toBe("idp-token"); + expect(auth.getPendingRedirectLogin(account)).toBeUndefined(); + expect(fake.acquireTokenSilent).toHaveBeenLastCalledWith({ + account, scopes: ["scope-api"], forceRefresh: true, + }); + }); + + it("discards an abandoned callback for logout or account changes", async () => { + fake.result = { account }; + const auth = await import("./msalInstance"); + await auth.initializeAuth(); + auth.discardRedirectLogin("different-account"); + expect(auth.getPendingRedirectLogin(account)).toBeDefined(); + auth.discardRedirectLogin(auth.getAccountKey(account)); + expect(auth.getPendingRedirectLogin(account)).toBeUndefined(); + + vi.resetModules(); + const next = await import("./msalInstance"); + await next.initializeAuth(); + expect(next.getPendingRedirectLogin(account)).toBeDefined(); + await next.logout(); + expect(next.getPendingRedirectLogin(account)).toBeUndefined(); + expect(fake.logoutRedirect).toHaveBeenCalledWith({ account }); + }); + + it("does not initialize MSAL when auth is disabled", async () => { + fake.enabled = false; + const auth = await import("./msalInstance"); + await auth.initializeAuth(); + expect(fake.initialize).not.toHaveBeenCalled(); + expect(fake.handleRedirectPromise).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/portal/src/lib/auth/msalInstance.ts b/apps/portal/src/lib/auth/msalInstance.ts index b2bab50e9..c30ead404 100644 --- a/apps/portal/src/lib/auth/msalInstance.ts +++ b/apps/portal/src/lib/auth/msalInstance.ts @@ -31,6 +31,38 @@ export const msalInstance = new PublicClientApplication(buildMsalConfiguration() let initialized = false; let initPromise: Promise | undefined; +/** An actual redirect result, not a restored account or silent token refresh. */ +export interface RedirectLogin { + readonly accountKey: string; +} + +let pendingRedirectLogin: RedirectLogin | undefined; + +export function getAccountKey(account: AccountInfo): string { + return JSON.stringify([ + account.homeAccountId, + account.localAccountId, + account.tenantId, + account.environment, + ]); +} + +export function getPendingRedirectLogin(account: AccountInfo): RedirectLogin | undefined { + return pendingRedirectLogin?.accountKey === getAccountKey(account) + ? pendingRedirectLogin + : undefined; +} + +/** Consume only after Scope accepted the login; failed attempts remain retryable. */ +export function consumeRedirectLogin(login: RedirectLogin): void { + if (pendingRedirectLogin === login) pendingRedirectLogin = undefined; +} + +/** An abandoned account must not leave a login event for a later session. */ +export function discardRedirectLogin(accountKey: string): void { + if (pendingRedirectLogin?.accountKey === accountKey) pendingRedirectLogin = undefined; +} + /** Pick a stable active account: the current one, else the first cached. */ function ensureActiveAccount(): AccountInfo | null { const active = msalInstance.getActiveAccount(); @@ -61,6 +93,7 @@ export async function initializeAuth(): Promise { const result = await msalInstance.handleRedirectPromise(); if (result?.account) { msalInstance.setActiveAccount(result.account); + pendingRedirectLogin = { accountKey: getAccountKey(result.account) }; } else { ensureActiveAccount(); } @@ -85,6 +118,7 @@ export async function initializeAuth(): Promise { * recover from a wedged/poisoned auth cache. Best-effort — never throws. */ export async function clearAuthState(): Promise { + pendingRedirectLogin = undefined; try { await msalInstance.clearCache(); } catch { @@ -115,6 +149,7 @@ export async function login(): Promise { /** Sign out via redirect, clearing the cached account. */ export async function logout(): Promise { await initializeAuth(); + pendingRedirectLogin = undefined; await msalInstance.logoutRedirect({ account: msalInstance.getActiveAccount() ?? undefined, }); diff --git a/apps/portal/src/lib/auth/wireApiAuth.ts b/apps/portal/src/lib/auth/wireApiAuth.ts index f9ad21fe3..f8006bfe2 100644 --- a/apps/portal/src/lib/auth/wireApiAuth.ts +++ b/apps/portal/src/lib/auth/wireApiAuth.ts @@ -7,7 +7,8 @@ * * Kept separate from `api-client.ts` so that module stays free of MSAL (and thus * unit-testable without a browser). Call {@link wireApiAuth} once at bootstrap, - * after {@link initializeAuth} has run. + * before initialization and the first render. Token acquisition itself waits + * for {@link initializeAuth}. */ import { setApiTokenProvider, setReauthHandler } from "../api-client"; import { acquireApiToken, acquireApiTokenRedirect, isAuthEnabled } from "./msalInstance"; @@ -23,7 +24,10 @@ let wired = false; * * No-op when the auth feature is disabled ({@link isAuthEnabled} is `false`) so * requests go out without an `Authorization` header and a `401` never triggers - * an interactive redirect — matching an API that does not verify tokens yet. + * an interactive redirect — preserving anonymous rollout mode. + * + * The token provider must never await the Scope user handshake: that handshake + * uses this very transport. AuthProvider/RequireAuth gate application queries. * * Idempotent. */ diff --git a/apps/portal/src/main.test.tsx b/apps/portal/src/main.test.tsx new file mode 100644 index 000000000..c964a70a7 --- /dev/null +++ b/apps/portal/src/main.test.tsx @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import type { AccountInfo } from "@azure/msal-browser"; +import { act, screen, waitFor } from "@testing-library/react"; +import { useQuery } from "@tanstack/react-query"; +import type { Root } from "react-dom/client"; +import type { ReactNode } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { api } from "@/lib/api"; +import { resetApiClient, setApiTokenProvider } from "@/lib/api-client"; +import { useFavicon } from "@/hooks/useFavicon"; + +const bootstrap = vi.hoisted(() => ({ + root: null as Root | null, + event: { accountKey: "callback-account" }, +})); +const account: AccountInfo = { + homeAccountId: "home", localAccountId: "subject", tenantId: "tenant", + environment: "login.example.test", username: "alice@example.test", +}; + +vi.mock("react-dom/client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRoot: (container: HTMLElement) => { + bootstrap.root = actual.createRoot(container); + return bootstrap.root; + }, + }; +}); + +vi.mock("@azure/msal-react", () => ({ + MsalProvider: ({ children }: { children: ReactNode }) => children, + useMsal: () => ({ accounts: [account], inProgress: "none" }), + useAccount: () => account, +})); + +vi.mock("@/lib/auth/msalInstance", async (importOriginal) => ({ + ...await importOriginal(), + initializeAuth: async () => {}, + isAuthEnabled: true, + isAuthConfigured: true, + getPendingRedirectLogin: () => bootstrap.event, + consumeRedirectLogin: vi.fn(), + discardRedirectLogin: vi.fn(), +})); + +vi.mock("@/lib/auth/wireApiAuth", () => ({ + wireApiAuth: () => setApiTokenProvider(() => "idp-token"), +})); + +vi.mock("@/App", () => ({ + App: () => { + // Match App's eager favicon effect plus an unconditionally mounted page. + useFavicon(); + useQuery({ queryKey: ["projects"], queryFn: () => api.listProjects() }); + return
Bootstrapped app
; + }, +})); + +afterEach(async () => { + await act(async () => bootstrap.root?.unmount()); + document.body.innerHTML = ""; + resetApiClient(); + vi.unstubAllGlobals(); +}); + +it("the real bootstrap/provider tree sends no flags, favicon or page requests before the login response", async () => { + document.body.innerHTML = '
'; + let resolve!: (response: Response) => void; + const lookup = new Promise((done) => { resolve = done; }); + const requests: string[] = []; + const fetchMock = vi.fn((request: Request) => { + const url = new URL(request.url); + const path = url.pathname + url.search; + requests.push(`${request.method} ${path}`); + if (request.method === "POST" && path === "/api/v1/users/me") return lookup; + return Promise.resolve(new Response("[]")); + }); + vi.stubGlobal("fetch", fetchMock); + await act(async () => { await import("./main"); }); + await waitFor(() => expect(requests).toEqual(["POST /api/v1/users/me"])); + expect(screen.queryByText("Bootstrapped app")).toBeNull(); + + await act(async () => resolve(new Response(JSON.stringify({ + id: "11111111-2222-4333-8444-555555555555", role: "user", + })))); + await screen.findByText("Bootstrapped app"); + await waitFor(() => expect(requests).toEqual(expect.arrayContaining([ + "GET /api/v1/feature-flags", "GET /api/v1/version", "GET /api/v1/projects", + ]))); + expect(requests[0]).toBe("POST /api/v1/users/me"); + expect(requests.filter((entry) => entry.includes("/users/me"))).toHaveLength(1); +}); diff --git a/apps/portal/src/main.tsx b/apps/portal/src/main.tsx index 45c552ce1..cef225350 100644 --- a/apps/portal/src/main.tsx +++ b/apps/portal/src/main.tsx @@ -13,6 +13,7 @@ import { ThemeProvider } from "@/contexts/ThemeContext"; import { AuthProvider } from "@/contexts/AuthContext"; import { ProjectProvider } from "@/contexts/ProjectContext"; import { Toaster } from "@/components/ui/sonner"; +import { RequireAuth } from "@/components/auth/RequireAuth"; import { msalInstance, initializeAuth } from "@/lib/auth/msalInstance"; import { wireApiAuth } from "@/lib/auth/wireApiAuth"; import "./index.css"; @@ -36,14 +37,17 @@ initializeAuth().finally(() => { - - - - - - - - + {/* Gate App's favicon effect and eager providers, not just routes. */} + + + + + + + + + + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index b6bcb966d..50410ab84 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -28,6 +28,12 @@ services: mem_limit: 1g build: target: dev + ports: + # Node inspector for VS Code. Keep host access loopback-only; .env.base + # offsets the host port so multiple worktrees can run concurrently. + - "127.0.0.1:${API_DEBUG_PORT:-9200}:9229" + environment: + NODE_INSPECT_PORT: "9229" develop: watch: - action: sync @@ -117,8 +123,14 @@ services: # trusted) using the per-worktree host port. redirectUri/postLogoutRedirectUri # default to window.location.origin, so they auto-match this worktree's # portal port. - VITE_AUTH_AUTHORITY: "https://localhost:${ENTRA_LOCAL_PORT:-8443}/${AUTH_TENANT_ID:-11111111-1111-1111-1111-111111111111}/v2.0" - VITE_AUTH_KNOWN_AUTHORITIES: "localhost:${ENTRA_LOCAL_PORT:-8443}" + VITE_AUTH_CLIENT_ID: "${VITE_AUTH_CLIENT_ID:-cccccccc-0000-0000-0000-000000000001}" + VITE_AUTH_AUTHORITY: "${VITE_AUTH_AUTHORITY:-https://localhost:${ENTRA_LOCAL_PORT:-8443}/${AUTH_TENANT_ID:-11111111-1111-1111-1111-111111111111}/v2.0}" + VITE_AUTH_KNOWN_AUTHORITIES: "${VITE_AUTH_KNOWN_AUTHORITIES-localhost:${ENTRA_LOCAL_PORT:-8443}}" + VITE_AUTH_SCOPES: "${VITE_AUTH_SCOPES:-api://cccccccc-0000-0000-0000-000000000005/access_as_user}" + VITE_AUTH_PROTOCOL_MODE: "${VITE_AUTH_PROTOCOL_MODE:-OIDC}" + VITE_AUTH_REDIRECT_URI: "${VITE_AUTH_REDIRECT_URI:-}" + VITE_AUTH_POST_LOGOUT_REDIRECT_URI: "${VITE_AUTH_POST_LOGOUT_REDIRECT_URI:-}" + VITE_AUTH_CACHE_LOCATION: "${VITE_AUTH_CACHE_LOCATION:-localStorage}" # Auth feature toggle (see ENV_VARIABLES.md → "Feature toggle"). Auth is ON # by default. To iterate on the Portal locally WITHOUT sign-in (e.g. before # the API verifies tokens), export VITE_AUTH_ENABLED_LOCAL=false. This flag diff --git a/docker-compose.yml b/docker-compose.yml index fac8bcd69..0e005a1cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -173,16 +173,16 @@ services: # entra-local-certs-init) so the browser trusts it with no manual cert step — # MSAL requires an https authority. See the auth docs. entra-local: - image: ghcr.io/cmaneu/entra-local:0.0.3 + image: ghcr.io/cmaneu/entra-local:0.1.1 profiles: [auth] ports: - "${ENTRA_LOCAL_PORT:-8443}:8443" environment: - # HTTPS with the mkcert-issued localhost cert. mkcert's CA is trusted on the - # host, so https://localhost: is trusted by the browser. + # The cert covers localhost (browser) and entra-local (Compose clients). TLS_ENABLED: "true" TLS_CERT: /certs/entra-local.pem TLS_KEY: /certs/entra-local-key.pem + NODE_EXTRA_CA_CERTS: /ca/rootCA.pem # The container binds 0.0.0.0:8443 internally; the host maps it to # ENTRA_LOCAL_PORT (offset per worktree). Pin PUBLIC_ORIGIN *and* ISSUER to # the host-facing origin so the discovery/JWKS/token endpoints and the @@ -197,11 +197,12 @@ services: # bind mount, so the emulator's `node` user can read the key on rootless / # UID-remapped engines. See entra-local-certs-init. - entra_local_certs:/certs:ro + - entra_local_ca:/ca:ro depends_on: entra-local-certs-init: condition: service_completed_successfully healthcheck: - test: ["CMD", "node", "-e", "require('https').get({host:'localhost',port:8443,path:'/health',rejectUnauthorized:false},(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] + test: ["CMD", "node", "-e", "require('https').get({host:'localhost',port:8443,path:'/health'},(r)=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] interval: 10s timeout: 5s retries: 5 @@ -222,22 +223,32 @@ services: profiles: [auth] restart: "no" command: - [ - "sh", - "-c", - "set -e; echo 'Staging entra-local certs for UID 1000...'; cp /certs-src/entra-local.pem /certs/entra-local.pem; cp /certs-src/entra-local-key.pem /certs/entra-local-key.pem; chown -v 1000:1000 /certs /certs/entra-local.pem /certs/entra-local-key.pem; chmod -v 0644 /certs/entra-local.pem; chmod -v 0640 /certs/entra-local-key.pem; echo 'Done'", - ] + - sh + - -c + - | + set -eu + echo 'Staging entra-local certs for UID 1000...' + cp /certs-src/entra-local.pem /certs/entra-local.pem + cp /certs-src/entra-local-key.pem /certs/entra-local-key.pem + cp /certs-src/rootCA.pem /ca/rootCA.pem + chown -v 1000:1000 /certs /certs/entra-local.pem /certs/entra-local-key.pem + chmod -v 0755 /certs /ca + chmod -v 0644 /certs/entra-local.pem /ca/rootCA.pem + chmod -v 0640 /certs/entra-local-key.pem + echo 'Done' volumes: - ./.certs:/certs-src:ro - entra_local_certs:/certs + # Clients receive only the public CA, never the emulator's private key. + - entra_local_ca:/ca # entra-local-init — one-shot: registers the dev Portal origin as an SPA # redirect URI on the seeded "Sample SPA" app so sign-in works with no manual # portal step. Idempotent (tolerates an already-registered URI). Talks to the # emulator over the compose network (https://entra-local:8443/admin/api); the - # cert is for `localhost`, so verification is disabled for this internal call. + # shared CA and entra-local certificate SAN keep TLS verification enabled. entra-local-init: - image: ghcr.io/cmaneu/entra-local:0.0.3 + image: ghcr.io/cmaneu/entra-local:0.1.1 profiles: [auth] restart: "no" entrypoint: ["node", "-e"] @@ -248,7 +259,7 @@ services: const base = 'https://entra-local:8443/admin/api/apps/' + appId + '/redirectUris'; const body = JSON.stringify({ uri, type: 'spa' }); const post = () => new Promise((res, rej) => { - const r = require('https').request(base, { method: 'POST', rejectUnauthorized: false, headers: { 'content-type': 'application/json' } }, (rsp) => { + const r = require('https').request(base, { method: 'POST', headers: { 'content-type': 'application/json' } }, (rsp) => { let d = ''; rsp.on('data', c => d += c); rsp.on('end', () => res({ status: rsp.statusCode, body: d })); }); r.on('error', rej); r.write(body); r.end(); @@ -267,6 +278,9 @@ services: })(); environment: PORTAL_PORT: "${PORTAL_PORT:-5100}" + NODE_EXTRA_CA_CERTS: /ca/rootCA.pem + volumes: + - entra_local_ca:/ca:ro depends_on: entra-local: condition: service_healthy @@ -312,6 +326,13 @@ services: restart: on-failure:5 ports: - "${API_PORT:-3100}:80" + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:80/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] + interval: 5s + timeout: 3s + retries: 12 + # Dev startup runs tsx and tsc concurrently under the API's CPU limit. + start_period: 120s env_file: # .env.local holds developer-managed values that worktree-env must not # overwrite: AZURE_AI_INFERENCE_ENDPOINT, AZURE_AI_INFERENCE_API_KEY, @@ -330,7 +351,29 @@ services: GIT_COMMIT: ${GIT_COMMIT:-development} BUILD_TIME: ${BUILD_TIME:-} SCOPE_ENVIRONMENT: ${SCOPE_ENVIRONMENT:-} + # Local Entra dev defaults: point the API at the same worktree-shifted + # `entra-local` issuer/JWKS and the API app registration the emulator + # exposes. This keeps auth enabled without requiring each developer to hand + # edit `.env.local` for every offset worktree. + AUTH_PROVIDER: "${AUTH_PROVIDER:-entra}" + AUTH_AUTHORITY: "${AUTH_AUTHORITY:-https://localhost:${ENTRA_LOCAL_PORT:-8443}/${AUTH_TENANT_ID:-11111111-1111-1111-1111-111111111111}}" + AUTH_ISSUER_TEMPLATE: "${AUTH_ISSUER_TEMPLATE:-https://localhost:${ENTRA_LOCAL_PORT:-8443}/{tenantid}/v2.0}" + AUTH_JWKS_URI: "${AUTH_JWKS_URI:-https://entra-local:8443/${AUTH_TENANT_ID:-11111111-1111-1111-1111-111111111111}/discovery/v2.0/keys}" + AUTH_API_CLIENT_ID: "${AUTH_API_CLIENT_ID:-cccccccc-0000-0000-0000-000000000005}" + AUTH_PORTAL_CLIENT_ID: "${AUTH_PORTAL_CLIENT_ID:-cccccccc-0000-0000-0000-000000000001}" + AUTH_SCOPES: "${AUTH_SCOPES:-api://cccccccc-0000-0000-0000-000000000005/access_as_user}" + AUTH_BOOTSTRAP_ADMINS: "${AUTH_BOOTSTRAP_ADMINS:-entra:11111111-1111-1111-1111-111111111111/aaaaaaaa-0000-0000-0000-000000000001}" + AUTH_BOOTSTRAP_TENANTS: "${AUTH_BOOTSTRAP_TENANTS:-11111111-1111-1111-1111-111111111111}" + AUTH_USER_CACHE_TTL_SECONDS: "${AUTH_USER_CACHE_TTL_SECONDS:-300}" + NODE_TLS_REJECT_UNAUTHORIZED: "1" + NODE_EXTRA_CA_CERTS: "${NODE_EXTRA_CA_CERTS-/ca/rootCA.pem}" + volumes: + - entra_local_ca:/ca:ro depends_on: + # Do not require local certificates when running without the auth profile. + entra-local-certs-init: + condition: service_completed_successfully + required: false mongodb: condition: service_healthy required: false @@ -344,7 +387,7 @@ services: image: alpine:3.21 depends_on: api: - condition: service_started + condition: service_healthy volumes: - ./apps/workers:/workers:ro - ./scripts/register-agent.sh:/register-agent.sh:ro @@ -538,9 +581,20 @@ services: dockerfile: apps/portal/Dockerfile args: <<: *npm-build-args + # Vite embeds these public IdP settings into the production bundle. + VITE_AUTH_CLIENT_ID: "${VITE_AUTH_CLIENT_ID:-cccccccc-0000-0000-0000-000000000001}" + VITE_AUTH_AUTHORITY: "${VITE_AUTH_AUTHORITY:-https://localhost:${ENTRA_LOCAL_PORT:-8443}/${AUTH_TENANT_ID:-11111111-1111-1111-1111-111111111111}/v2.0}" + VITE_AUTH_KNOWN_AUTHORITIES: "${VITE_AUTH_KNOWN_AUTHORITIES-localhost:${ENTRA_LOCAL_PORT:-8443}}" + VITE_AUTH_SCOPES: "${VITE_AUTH_SCOPES:-api://cccccccc-0000-0000-0000-000000000005/access_as_user}" + VITE_AUTH_PROTOCOL_MODE: "${VITE_AUTH_PROTOCOL_MODE:-OIDC}" + VITE_AUTH_REDIRECT_URI: "${VITE_AUTH_REDIRECT_URI:-}" + VITE_AUTH_POST_LOGOUT_REDIRECT_URI: "${VITE_AUTH_POST_LOGOUT_REDIRECT_URI:-}" + VITE_AUTH_CACHE_LOCATION: "${VITE_AUTH_CACHE_LOCATION:-localStorage}" profiles: [portal] ports: - "${PORTAL_PORT:-5100}:80" + environment: + SCOPE_AUTH_ENABLED: "${SCOPE_AUTH_ENABLED:-true}" depends_on: api: condition: service_started @@ -844,6 +898,7 @@ volumes: lowkey_vault_data: entra_local_data: entra_local_certs: + entra_local_ca: gateway_cert: copilot_workspace: claude_har_output: diff --git a/docs/README.md b/docs/README.md index c548c4e62..98f319373 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ Central documentation hub for the Scope platform — an AI coding agent benchmar | [Application Design](architecture/app-design.md) | Data models, API design, judge pipeline, queue patterns, criteria system | | [VS Code Web Worker](architecture/vscode-web-worker.md) | XState chat machine, GitHub auth flow, ARIA snapshots, AI dev loop | | [Token Manager](architecture/token-manager.md) | Capability-based token management, validation, round-robin distribution | +| [Authentication & RBAC](architecture/auth-rbac.md) | Explicit-login IdP authentication, Redis user-access caching, Portal handshake; deferred RBAC roadmap | | [Worker Requirements](architecture/worker-requirements.md) | Requirements checklist for coding agent workers | | [Worker Compliance](architecture/worker-compliance.md) | Per-worker compliance matrix against requirements | | [Database Migrations](architecture/db-migrations.md) | Lightweight MongoDB migration framework, writing and running migrations | diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md index 81580f1d7..7dfe14d7d 100644 --- a/docs/architecture/app-design.md +++ b/docs/architecture/app-design.md @@ -30,6 +30,31 @@ flowchart LR ## Data Model +### Application users and access resolution + +`users._id` is a Scope-owned UUID. The unique external identity is +`(idp, idpTenant, idpSubject)` (`idp`, Entra `tid`, Entra `oid`), never email. +`UserStore.upsertOnLogin()` is called only by the explicit +`POST /api/v1/users/me` path for JIT/profile/`lastLoginAt`/eligible +bootstrap-admin writes. `lastLoginAt` records that upsert, not request activity or +proof of an interactive prompt; the disabled check still occurs after the upsert. + +After IdP verification, `UserAccessResolver.resolveExisting()` uses a validated +`RedisUserAccessCache` snapshot or `UserStore.findByIdentity()` on cache miss/outage. +It never upserts. The versioned cache key includes independently encoded Mongo +database namespace, provider, tenant, and subject; only active users are positively +cached. Fixed/non-sliding TTL defaults to 300 seconds +(`AUTH_USER_CACHE_TTL_SECONDS`), so DB-only role/disable edits can remain stale until +expiry. Redis failure falls back to Mongo, not anonymous access. + +The Portal handshake uses POST `/me` after callback and GET `/me` after an +MSAL-cached reload, gating all queries until its API-authoritative UUID/role arrives. +The singular stored role is metadata today: the permission bundles, ownership +enforcement, service credentials, and internal JWTs in +[Authentication & RBAC](auth-rbac.md) are deferred, not a global API lockdown. + +### Benchmark entities + Runs are the central entity: ```mermaid @@ -95,7 +120,8 @@ To support submitting an AGENTS.md prompt with a run, the request carries: A **Project** (`projects` collection, `ProjectStore`) is the top-level container that partitions all user-facing data. Every scoped entity carries one **immutable `projectId`**, set at creation and never changed. This is the data-organization layer only — it is a -**filter, not a security boundary** (access control lives in `auth-rbac.md`; any caller may +**filter, not a security boundary** (future ownership/RBAC is specified in +[`auth-rbac.md`](auth-rbac.md); any caller admitted by the current auth rollout may pass any `projectId`). ### Scoped vs. unscoped entities @@ -357,6 +383,10 @@ Multiple agents or versions may advertise the same queue. The scheduler deduplicates that queue and claims requests only for the exact registered `workerType` + `agentVersion` targets mapped to it. +The API owns only the report-generation queue. Its `RouteContext` exposes one +`reportQueueClient`, not coding-agent queue clients or a queue-client factory; +those belong to the scheduler. + Capabilities are explicit opt-ins. The supported keys are `supportsReasoningEffort`, `supportsMcpServers`, `supportsSkills`, and `supportsExtensions`; an omitted or false key means unsupported. @@ -660,6 +690,20 @@ Zod schemas live in `packages/shared/src/schemas/` (16 files, ~78 schemas) so th OpenAPI route registrations live in `apps/api/src/openapi/routes/` — one file per resource group. The registry and generator are in `apps/api/src/openapi/registry.ts`. +### Authentication metadata + +The registry declares `bearerAuth` as an HTTP bearer scheme for unchanged IdP +access tokens. `apiRoute()` accepts optional OpenAPI `security` metadata; +both `GET` and `POST /api/v1/users/me` set `security: [{ bearerAuth: [] }]`. In Swagger UI, +use **Authorize** and paste the access token without its `Bearer` prefix. + +The requirement is operation-scoped: there is no global security requirement, +and existing anonymous endpoints are not advertised as protected. This metadata +does not install authentication or authorization guards; runtime enforcement +remains in the existing middleware and route handlers. + +### Generated artifact + The static documentation site consumes the committed artifact at `website/src/openapi/scope-openapi.json`. Generate it from the API registry with `pnpm --filter api generate:openapi` rather than fetching diff --git a/docs/architecture/auth-rbac.md b/docs/architecture/auth-rbac.md index 2bae28ad6..12fb57c2c 100644 --- a/docs/architecture/auth-rbac.md +++ b/docs/architecture/auth-rbac.md @@ -1,16 +1,25 @@ # Authentication & RBAC -> Status: **Proposed** — implementation plan. Date: 2026-06-10. +> Status: **Explicit-login authentication and user-access caching implemented; +> full RBAC, ownership enforcement, CLI interactive login, and internal-token +> designs remain deferred.** Original RBAC proposal: 2026-06-10; auth contract updated: +> 2026-09-18. This is not a statement that every deployed environment has migrated. -## Problem +## Scope and deferred goals -Scope currently has **no authentication or authorization**. The API (`apps/api/`) -serves every endpoint unauthenticated, the Portal (`apps/portal/`) talks to it over a -same-origin nginx proxy with no credentials, and the CLI (`apps/cli/`) issues bare -`fetch` calls against `SCOPE_API_URL`. Any caller can read, submit, mutate, or delete -any run and any catalog resource. +Scope verifies IdP access tokens and resolves an active application user, but does +**not yet enforce full roles/permissions or ownership on its routes**. The existing +no-token/auth-not-configured anonymous rollout and public endpoints remain supported. +An authenticated identity that is missing or disabled in Scope is **not** anonymous. -We need to: +The implemented boundary is narrow: only **`POST /api/v1/users/me`** +may JIT-create a user, refresh their profile/`lastLoginAt`, or apply bootstrap-admin +Every GET `/users/me` and other -authenticated routes verify the IdP token, +then read an existing active principal through one Redis-backed resolver. Clients +continue to send the **unchanged IdP access token on every call**; there is no +`/auth/login`, token exchange, Scope session JWT, or new signing-key configuration. + +The following are **deferred RBAC goals**, not guarantees of the current rollout: 1. **Authenticate** every human-facing caller (CLI, API, Portal) using **Microsoft Entra ID** (formerly Azure AD). @@ -26,15 +35,18 @@ We need to: is discoverable, usable, and editable only by its owner; shared data is globally discoverable and usable by everyone but **read-only unless you own it**. An `admin` sees everything. (See §5.) -5. Keep **anonymous/public mode out of scope.** The `anonymous` principal carries **zero +5. Keep a new **anonymous/public product mode out of scope.** In the future permission + model, the `anonymous` principal carries **zero permissions** and is rejected by any permission-gated route. Treating it as a named principal is a guard *mechanism* convenience only — it is **not** a public experience and must not be granted permissions until a future, explicit public/demo mode is introduced over **public-only** data (see Open Question J). -> **Primary milestone — authenticate the user.** The one must-ship outcome of this work is -> **user authentication across Portal, API, and CLI** (Entra ID identity, with ownership -> scoping built on it). Everything else is sequenced around that. In particular, +> **Current milestone — explicit enrollment, then cached application access.** +> Portal/API authentication uses the existing Entra identity and singular Scope `role`. +> Already-enrolled CLI/raw-bearer callers remain compatible; a new identity must +> explicitly POST `/users/me` before other authenticated requests. +> CLI device-code login and ownership enforcement are separate work. In particular, > **Scope-issued PAT / API tokens delivered through `SCOPE_TOKEN`** are very likely the > right long-term answer for **user CI integrations and user-attributed automation**, but > they are **explicitly out of scope for, and must not block, the user-auth milestone** @@ -54,15 +66,16 @@ must be resolved with stakeholders before or during implementation. --- -## Current State (investigation summary) +## Current implementation -| Component | Today | Relevant files | +| Component | In this branch | Relevant files | |-----------|-------|----------------| -| API | Express app, no auth middleware. Routes registered via `apiRoute()` helper that also feeds the OpenAPI registry. CORS open, `express.json()` only. | [apps/api/src/index.ts](../../apps/api/src/index.ts), [apps/api/src/openapi/api-route.ts](../../apps/api/src/openapi/api-route.ts), [apps/api/src/route-context.ts](../../apps/api/src/route-context.ts) | +| API | Verifies the IdP signature and claims before any access-cache lookup. `/users/me` owns explicit enrollment; subsequent middleware resolves existing active users. Public/anonymous rollout is unchanged. | [apps/api/src/index.ts](../../apps/api/src/index.ts), [auth/middleware.ts](../../apps/api/src/auth/middleware.ts), [routes/users.ts](../../apps/api/src/routes/users.ts) | +| User access | `UserAccessResolver` reads a validated Redis snapshot or the exact `(idp, idpTenant, idpSubject)` Mongo record. Only explicit login invokes `upsertOnLogin`; no per-request JIT. | [auth/user-access-resolver.ts](../../apps/api/src/auth/user-access-resolver.ts), [auth/user-access-cache.ts](../../apps/api/src/auth/user-access-cache.ts), [auth/user-store.ts](../../apps/api/src/auth/user-store.ts) | | Runs data model | `RequestResponseSchema` + embedded `RunStateSchema`; history in `runs` collection (`RunHistoryDocumentSchema`). **No owner field.** | [packages/shared/src/schemas/request.ts](../../packages/shared/src/schemas/request.ts) | | Run listing | Cursor-paginated `GET /api/v1/requests` with filters; no per-user scoping. | [apps/api/src/routes/requests.ts](../../apps/api/src/routes/requests.ts) | -| CLI | `commander` CLI, each command takes `-u/--url` (`SCOPE_API_URL`), bare `fetch`, **no auth header**. | [apps/cli/src/commands/run.ts](../../apps/cli/src/commands/run.ts), [apps/cli/src/index.ts](../../apps/cli/src/index.ts) | -| Portal | React 19 SPA, `fetch` against same-origin `/api/v1` via nginx proxy, **no token**. | [apps/portal/src/lib/api.ts](../../apps/portal/src/lib/api.ts), [apps/portal/src/main.tsx](../../apps/portal/src/main.tsx), [apps/portal/nginx.conf](../../apps/portal/nginx.conf) | +| CLI | Centralized `apiFetch()` injects `SCOPE_TOKEN` as a raw IdP bearer. No CLI interactive-login implementation is added here. New identities must explicitly enroll; ordinary CLI calls never enroll them. | [apps/cli/src/utils/api-client.ts](../../apps/cli/src/utils/api-client.ts), [CLI guidance](../../.agents/skills/scope-cli/SKILL.md) | +| Portal | MSAL supplies the IdP bearer; `AuthProvider` gates queries on `/users/me`. Fresh callback uses POST; cached-account reload uses GET. Scope UUID and role come from the API, not account claims. | [AuthContext.tsx](../../apps/portal/src/contexts/AuthContext.tsx), [apps/portal/src/lib/api.ts](../../apps/portal/src/lib/api.ts), [apps/portal/src/main.tsx](../../apps/portal/src/main.tsx) | | Token Manager | Already has a `users`-like pattern for **provider** credentials (not app users). Reuse its KeyVault/Mongo patterns, not its schema. | [apps/token-manager/src/account-routes.ts](../../apps/token-manager/src/account-routes.ts) | | Migrations | `mongo-migrate-ts`, numbered files with `up()`/`down()`. CosmosDB-compatible constraints apply. | [packages/db-migrations/src/migrations](../../packages/db-migrations/src/migrations) | @@ -79,8 +92,9 @@ over HTTP (`SCOPE_MT_API_URL`) to read a specific run and write insights/reports The **scheduler** ([apps/scheduler](../../apps/scheduler)) does **not** call the API (it touches MongoDB + Storage Queues directly), so it needs no API auth. -> **This makes service-to-service auth an immediate requirement, not a future one.** The -> moment ownership scoping (§5) lands, `GET /api/v1/requests/:id` becomes owner-scoped and +> **Service-to-service auth is a co-requisite of future ownership enforcement.** It +> is not introduced by explicit login/caching. The moment ownership scoping (§5) lands, +> `GET /api/v1/requests/:id` becomes owner-scoped and > the report-generator — which has no user identity — would receive `404`s and its > insight/report writes would be rejected. Service-to-service auth (§6) must therefore > ship **together with** ownership scoping. Because the report-generator operates on **one @@ -92,35 +106,22 @@ touches MongoDB + Storage Queues directly), so it needs no API auth. ## Architecture -### Overview +### Overview — current IdP-token flow ```mermaid flowchart TB - subgraph Clients - CLI[CLI
device-code flow] - Portal[Portal SPA
auth-code + PKCE] - end - - subgraph IdP[Microsoft Entra ID] - OIDC[OIDC / OAuth2
JWKS, token endpoint] - end - - subgraph API[API service] - MW[authn middleware
verify JWT via AuthProvider] - RBAC[authz: role + ownership scope] - Routes[Routes apiRoute()] - Users[(users collection)] - end - - CLI -->|1 device-code login| OIDC - Portal -->|1 redirect login| OIDC - OIDC -->|access token JWT| CLI - OIDC -->|access token JWT| Portal - CLI -->|2 Bearer token| MW - Portal -->|2 Bearer token| MW - MW -->|verify sig/aud/iss/exp
JWKS cache| OIDC - MW -->|JIT upsert + role lookup| Users - MW --> RBAC --> Routes + Clients[Portal or bearer client] -->|unchanged IdP access token| Verify[verifyAccessToken
signature and claims first] + Verify -->|verified identity| Me[users/me] + Verify -->|other routes| Existing[resolveExisting] + Me -->|GET or HEAD| Existing + Me -->|POST only| Login[enrollOnLogin] + Login -->|profile and upsertOnLogin| Users[(MongoDB users)] + Existing -->|GET| Cache[(Redis active-user cache)] + Cache -->|valid hit: no Mongo| Principal[Scope UUID and role] + Existing -->|miss or unavailable: findByIdentity| Users + Users --> Validate[Validate identity and active access] + Validate -->|best-effort SET EX| Cache + Validate --> Principal ``` ### 1. Pluggable IdP abstraction (`packages/shared/src/auth/`) @@ -136,8 +137,11 @@ export interface VerifiedIdentity { idpSubject: string; /** Provider id, e.g. "entra", "google", "oidc". */ idp: string; + /** Entra `tid`; part of the durable identity key. */ + idpTenant: string; email?: string; - name?: string; + displayName?: string; + emailVerified?: boolean; } export interface AuthProvider { @@ -167,12 +171,17 @@ export interface AuthClientConfig { - **Multi-tenant**: validates against the Entra **common/organizations** issuer pattern and accepts **any tenant** — no `tid` pinning in code. Tenant restriction (if any) is - configured at the **App Registration** level. JWKS is resolved per-tenant via OIDC - discovery (or common metadata) and cached by `(tenant, kid)` with TTL + rotation. + configured at the **App Registration** level. `jose` caches keys from the configured + JWKS endpoint (`AUTH_JWKS_URI`, otherwise derived from `AUTH_AUTHORITY`) and handles + key rotation. Scope requires the selected JWK to publish an `issuer` and enforces that + Entra-specific key restriction: a `{tenantid}` key issuer is expanded from the token's + `tid`, while a tenant-specific key issuer must match exactly. - Verifies signature (RS256), `iss` (per-tenant issuer template), `aud` - (`AUTH_API_CLIENT_ID`), `exp`, `nbf`. -- Extracts `oid` → `idpSubject`, `tid` → `idpTenant`, `preferred_username`/`email` - (+ the `email_verified`/`verified_primary_email` signal where present), `name`. The + (`AUTH_API_CLIENT_ID`), `exp`, `nbf`. Both the configured token issuer template and + the selected signing key's issuer must match; missing or malformed key issuer metadata + fails closed. +- Extracts `oid` → `idpSubject`, `tid` → `idpTenant`, `email` (falling back to + `preferred_username`), optional boolean `email_verified`, and `name`. The `(idp, idpTenant, idpSubject)` triple — **not** email — is the durable identity key (see §2 and Open Question D). - Uses `jose` for JWKS + verification (no heavyweight MSAL dependency on the API). @@ -183,21 +192,25 @@ The provider is instantiated from env in API bootstrap: AUTH_PROVIDER=entra # selects implementation AUTH_AUTHORITY=https://login.microsoftonline.com/common # multi-tenant (or /organizations) AUTH_API_CLIENT_ID= # expected audience (pinned) -AUTH_CLIENT_ID= # CLI/portal client id (hardcoded by clients too) +AUTH_CLI_CLIENT_ID= # public CLI client id +AUTH_PORTAL_CLIENT_ID= # public Portal client id AUTH_SCOPES=api:///access_as_user # Bootstrap admins are matched on the *verified subject*, NOT a mutable email — see §2 / Q C: AUTH_BOOTSTRAP_ADMINS=entra:/,entra:/ # (idp:tenant/subject) tuples AUTH_BOOTSTRAP_TENANTS=, # tenant allowlist that bootstrap may apply within +AUTH_USER_CACHE_TTL_SECONDS=300 # positive safe integer; default only when unset # Tenant filtering, if needed, is enforced at the App Registration — not here. ``` > **No dev/bypass mode — by design.** There is **no `AUTH_ENABLED` switch and no -> env-selectable synthetic principal**. The middleware **always** verifies a real token; no -> environment variable, header, or flag can mint a user or elevate a role. The previous +> env-selectable synthetic principal**. With auth configured, every non-public bearer +> request verifies a real token before accessing Redis or MongoDB. No environment +> variable, header, or flag fabricates an authenticated principal. The previous > `local-user`/`local-admin` + `X-Dev-User`/`DEV_USER` bypass is **removed entirely** — it > was a standing privilege-escalation and "ships to prod by accident" risk. > -> Local development authenticates against a **real IdP** like every other environment. A -> dedicated **Entra ID local emulator** (built as a **separate project**) will provide a +> Auth-not-configured and no-token requests retain the existing anonymous rollout +> behavior; that does not enroll or authenticate anyone. Local sign-in uses a real +> token from the **Entra ID local emulator** (a **separate project**), which provides a > standards-compliant local OIDC issuer; Scope consumes it purely as **another IdP > configuration** (`AUTH_AUTHORITY`/`AUTH_API_CLIENT_ID`/JWKS pointed at the emulator) via > the existing `AuthProvider` abstraction — **no Scope code path knows it is "dev".** @@ -208,9 +221,14 @@ Authorization lives entirely in **our** database, not in the IdP, so RBAC is por across IdPs and survives IdP migration. The IdP only proves *identity*; Scope owns *authorization*. -**Permissions are the atomic unit.** A permission is a namespaced `resource:action` -string, e.g. `scope/run:write`. A **role** is just a named bundle of permissions. Today -we ship exactly two roles (`user`, `admin`), but because the model is +**Current model:** the stored user's singular `role` (default `user`, eligible +bootstrap promotion to `admin`) is returned as metadata, not enforced as route RBAC. +The permission types, overrides, and role bundles below are **deferred design**, +not the current request/response contract. + +**Deferred: permissions are the atomic unit.** A permission is a namespaced +`resource:action` string, e.g. `scope/run:write`. A **role** is a named bundle of +permissions. The proposed first roles are `user` and `admin`; because the model is permission-first, adding custom roles or per-user permission overrides later is **not** a schema change. @@ -256,7 +274,7 @@ export const ROLE_PERMISSIONS: Record = { > a required permission is **never** satisfied by the empty/anonymous set. ```ts -// users collection +// Proposed RBAC extension of the users collection; permission overrides are deferred. { _id: string, // **Scope User ID** — app-owned UUID; this is what // `ownerId` references everywhere (NOT the idpSubject). @@ -266,8 +284,8 @@ export const ROLE_PERMISSIONS: Record = { idpTenant: string, // Entra `tid` — part of the identity key (multi-tenant) idpSubject: string, // Entra `oid` — stable per (tenant, user); identity link only email?: string, // mutable, advisory; never an authorization input - emailVerified?: boolean, // captured when the IdP asserts it (bootstrap gate) - name?: string, + emailVerified?: boolean, // captured when the IdP asserts it (email storage only) + displayName?: string, role: UserRole, // "user" | "admin" (persisted role union only) /** Optional explicit grants/denies layered on top of the role. Empty today; * present in the schema so future custom permissions need no migration. */ @@ -275,7 +293,7 @@ export const ROLE_PERMISSIONS: Record = { permissionsRemove?: Permission[], createdAt: Date, updatedAt: Date, - lastLoginAt?: Date, + lastLoginAt?: Date, // explicit enrollment POST time, not request activity disabledAt?: Date, // soft-disable } ``` @@ -290,7 +308,8 @@ export const ROLE_PERMISSIONS: Record = { `permissionsRemove`. The authz layer always checks **permissions**, never role names directly — so swapping or adding roles never touches route code. -**The `anonymous` principal is a guard *mechanism*, not a public experience.** A reserved, +**Deferred permission model: the `anonymous` principal is a guard *mechanism*, not a +public experience.** A reserved, non-persisted principal (`{ id: "anonymous", role: "anonymous", permissions: [] }`) represents unauthenticated callers so route guards have a uniform shape. It carries **zero permissions** and **any** permission-gated route rejects it. This convenience does @@ -300,57 +319,199 @@ default of "just check the permission" would silently expose data if anyone ever set in v1**; a future public/demo mode is a deliberate, explicit change scoped to public-only data (Open Questions J), not an emergent property of this principal. -**JIT provisioning**: on first successful token verification, upsert a `users` doc with -default role `user`. **Admin bootstrap is identity-keyed, not email-keyed** (Entra +**Current JIT provisioning**: only an actual +`POST /api/v1/users/me`, after successful token verification, calls +`UserAccessResolver.enrollOnLogin()` and `UserStore.upsertOnLogin()`. A missing user +gets a Scope-owned UUID and default role `user`; an existing user's profile and +`lastLoginAt` are refreshed even if Redis already contains an active snapshot. +Every GET `/me`, cache expiry, and all other routes never upsert, enrich, promote, +or write `lastLoginAt`. + +`lastLoginAt` means **the explicit login upsert time**, not proof of an interactive +IdP prompt or callback: callers can invoke/retry the endpoint themselves. The existing +ordering is preserved: **the upsert (including profile/timestamp/promotion writes) +occurs before the resolver checks `disabledAt`**. A disabled user's login request can +therefore update those fields before returning `403`; it never admits/caches that user. + +**Admin bootstrap is identity-keyed, not email-keyed** (Entra `email`/`preferred_username` is mutable and not guaranteed verified, and in multi-tenant mode any tenant can sign users in): -- A user is bootstrapped to `admin` **only if** their `(idp, idpTenant, idpSubject)` +- On explicit login, a user is bootstrapped to `admin` **only if** their verified + `(idp, idpTenant, idpSubject)` appears in `AUTH_BOOTSTRAP_ADMINS` **and** their `idpTenant` is in `AUTH_BOOTSTRAP_TENANTS`. Email is **never** the match key. +- Bootstrap does not require `email` or `email_verified`; ordinary Entra workforce + and local-emulator access tokens can bootstrap without those claims. - `email_verified` (where the IdP asserts it) is required before any email is even stored as advisory; an unverified email never influences a grant. - **Bootstrap promotes but does not silently demote.** Presence in the list grants admin; *removal* from the list does **not** auto-demote a sitting admin (that requires an - explicit admin action via §6), so a bad ConfigMap edit can't quietly strip admins. The - reconcile is **append-only promotion**, logged to the security audit (§F) on every change. - -Index: unique compound `(idp, idpTenant, idpSubject)`; secondary on `email` (advisory -lookup only). Folding `idpTenant` into the key is mandatory — `oid` is unique only *within* + explicit administrative change), so a bad ConfigMap edit can't quietly strip admins. + Admin mutation endpoints and the durable security audit (§F) remain deferred. + +Index: database-enforced unique compound `(idp, idpTenant, idpSubject)`, named +`uniq_identity`. Both backends have a non-unique `email` index for advisory +lookup: sparse on native MongoDB, non-sparse on CosmosDB. Migration 029 +creates the Cosmos identity index with the collection and refuses incompatible +existing collections without deleting data or weakening uniqueness. See +[migration 029](db-migrations.md#migration-029-users-identity-uniqueness) for +backend handling and the remaining live-Cosmos validation. + +Folding `idpTenant` into the key is mandatory — `oid` is unique only *within* a tenant, so `(idp, idpSubject)` alone collides across tenants and mis-identifies guest/B2B users (Open Question D). ### 3. API authentication middleware -A single Express middleware mounted **before** route registration: - -1. Skip public routes (`/health`, `/ready`, `/about`, `/openapi.json`, - `/api/v1/version`). **There is no `/api/v1/auth/config`** — clients hardcode their IdP - config (§7/§8). -2. Extract `Authorization: Bearer `. Missing ⇒ attach the `anonymous` principal - (routes that require a permission will then return `401`/`403`). -3. Verify the token. Two issuer paths share one shape (`AuthProvider`-style verification): - - **IdP token** (`iss` = Entra) ⇒ `authProvider.verifyAccessToken(token)` ⇒ - `VerifiedIdentity` (invalid ⇒ `401`). - - **Scope internal token** (`iss = scope-api`) ⇒ verify against the Scope **public** - key, `aud = scope-internal`, `exp`, and `jti` against the revocation list (§6). -4. JIT-upsert `users`, load role, compute **effective permissions**, attach - `req.user: AuthenticatedUser` - (`{ id, role: PrincipalRole, permissions, email, idp, idpTenant, idpSubject, isService? }`). -5. **Liveness/revocation re-check on *every* path** (not just the IdP path): if the - resolved user's `disabledAt` is set ⇒ `403`; if a carried `jti` is revoked ⇒ `401`. - Internal tokens that carry permissions are re-validated against the live user wherever - feasible (see §6 — preference is to carry `sub` only and re-resolve downstream). -6. **`req.user.id` must never be `"system"` for a live caller.** The reserved `"system"` - id is a backfill sentinel only (§5); the middleware refuses to ever assign it to an - authenticated principal, and treats any token that resolves to it as a hard `401`. A - missing/fallback user is `anonymous`, never `"system"`. - -`AuthenticatedUser` is added to the `TypedRequest` type; it rides on the request object -(no change to `RouteContext`). A typed accessor `getUser(req)` and a -`hasPermission(user, perm)` helper (wildcard semantics per §2) are provided. - -### 4. Route-level authorization +Registration in `apps/api/src/index.ts` is deliberately ordered: + +1. CORS/body parsing and `/users/me` **`Cache-Control: no-store`** response policy. +2. `createAuthMiddleware()` verifies credentials, preserving exclusions for `/health`, + `/ready`, `/about`, `/openapi.json`, `/api/v1/version`, and `/api-docs`. +3. `registerUsersRoutes()` registers `/users/me` through `apiRoute()`. +4. `createUserAccessMiddleware()` resolves existing users for the remaining routes. +5. Other routes in their existing relative order, then `authErrorHandler` (typed + auth/access codes) and the existing logged unexpected-error handler. + +Verification attaches request-local `req.auth = { identity, token }`, not application +access. The raw bearer is used only as needed for explicit-login enrichment; it is +never persisted, put in Redis, or logged. `req.user` is populated only after access +resolution (or with the existing anonymous principal when no token/auth configuration +is present). There is **no Scope-token verifier or query-based middleware bypass**: +adding `?login=true` to a different route does not enroll a user. + +#### `/users/me` method contract + +Both methods require a verified identity, return the same response (`id`, `role`, +optional `email`, `displayName`, `idp`, `idpTenant`), and set `req.user` from the +shared resolver. `id` is the **Scope UUID**, never Entra `oid`. + +| Request | Behavior | +| --- | --- | +| `POST /api/v1/users/me` | Explicit enrollment/profile/timestamp/bootstrap writes, then access validation and cache warming. Returns `200` with the current-user representation. | +| `GET /api/v1/users/me` | Read-only existing-user resolution. | +| Other values, repeated `login`, arrays, objects, or an empty value | `400`; no enrollment. | +| `HEAD /api/v1/users/me?login=true` | Read-only resolution; Express dispatch to the GET handler must not cause JIT. | +| `?login=true` on another route | Normal existing-user resolution; no enrollment. | + +Only the POST has side effects. All `/users/me` responses, including errors, are +`Cache-Control: no-store`; clients also request `cache: "no-store"`. **Do not prefetch, +poll, automatically retry transient failures, or conditionally HTTP-cache the +enrollment POST.** An explicit user retry is allowed. The existing one-time `401` +token-refresh retry is safe because authentication fails before enrollment. + +#### Shared resolver and Redis contract + +`UserAccessResolver` owns the same mapping, identity/reserved-ID validation, +disabled check, and cache warming for both paths. The Mongo identity lookup is exact: +`(idp, idpTenant, idpSubject)` = `(idp, tid, oid)` for Entra. Email, browser account +IDs, token text, and client-supplied Scope IDs are never lookup keys. + +`RedisUserAccessCache` implements the API-local `UserAccessCache` interface (`get`, +`set`, `delete`, `close`) using `ioredis` and existing `REDIS_HOST`, `REDIS_PORT`, +`REDIS_PASSWORD`, and `REDIS_TLS` settings. There is no process-local access cache. + +Canonical key (each variable component is independently `encodeURIComponent`-encoded): + +```text +auth-user:v1:::: +``` + +The namespace is the configured **MongoDB database name**. Independent Scope databases +sharing Redis must use distinct database names/namespaces (or separate Redis instances); +the identity tuple alone is insufficient deployment isolation. A minimal version-1 +snapshot contains Scope ID, singular role, verified identity tuple, and optional +profile fields — **no bearer, negative entry, IdP-derived permissions, or Mongo object**. +Only existing active human principals are cached. Invalid JSON, unsupported versions, +malformed/reserved IDs, and mismatched tuples are logged, evicted best-effort, and +treated as misses rather than authorization. + +`AUTH_USER_CACHE_TTL_SECONDS` defaults to **300 only when unset**. A supplied value +must be a positive safe integer; blank, zero, negative, fractional, nonnumeric, or +unsafe values fail startup, even when IdP auth is disabled. Setting a valid value +alone does not enable IdP auth. +Writes use atomic `SET ... EX `; a hit only performs `GET`, so expiry is +**fixed/non-sliding**. Explicit login or a successful Mongo fallback starts a fresh TTL. + +Redis results distinguish hit, miss, and unavailable. Expected read/write/delete +failures are rate-limited in logs (without tokens or cached PII), with recovery +logging. A missing/blank Redis host creates no Redis client and reports unavailable +with a rate-limited warning, so resolution uses Mongo. Connection/command waits and +reconnect backoff are bounded; offline command queuing/replay is +disabled. On miss/unavailability, Mongo is authoritative; a failed cache write does +not discard a successful Mongo result. Unexpected application errors are not converted +to cache misses or success. + +**Consistency:** database-only role/disable changes may remain invisible until the +active snapshot's TTL expires. Hits do not extend that window. If a database lookup +or login discovers a missing, disabled, or invalid user, it denies access and +best-effort evicts any old entry; it never negatively caches the result. Future +role/disable mutation endpoints **must evict the matching key**. This cache is not a +browser session/revocation store; Portal logout does not delete shared Redis access. + +#### Method-level flow walkthrough + +1. **Fresh Portal callback → explicit login.** `initializeAuth()` records the + account-bound redirect result; `getAccountKey()` and `getPendingRedirectLogin()` + associate it with the current account. `wireApiAuth()` keeps the existing MSAL bearer + transport. `AuthProvider` calls `api.enrollCurrentUser({ signal })` + before mounting/querying authenticated application data. `createAuthMiddleware()` + calls `AuthProvider.verifyAccessToken()` first. The POST `/users/me` handler calls + `UserAccessResolver.enrollOnLogin(identity, token)`, which bypasses cache reads, + invokes `ProfileEnricher.enrich()` (claims-only today), then + `UserStore.upsertOnLogin()`. The resolver validates/maps the stored result and calls + `RedisUserAccessCache.set()` best-effort before returning the Scope user. Only a + successful handshake calls `consumeRedirectLogin()` and enables application queries. +2. **Ordinary request → cache hit.** Verification still runs first; for a normal + route, `createUserAccessMiddleware()` calls `resolveExisting(identity)`. + `RedisUserAccessCache.get()` validates the snapshot and tuple, then returns it. + No Mongo read/write, enrichment, promotion, or `lastLoginAt` update occurs. +3. **Miss/expiry/invalid entry.** After verification, `resolveExisting()` calls + `cache.get()`, then `UserStore.findByIdentity()` for that exact tuple. + The shared validator rejects missing/disabled/reserved users or maps an active user + and calls `cache.set()`. Expiry never triggers JIT. +4. **Redis unavailable.** `get()` reports unavailable and logs with rate limiting. + `resolveExisting()` follows the same Mongo read/validation path. `set()`/`delete()` + failure is best-effort; required Mongo failures still fail the request. Caching + resumes after Redis recovers without replaying offline writes. +5. **Cached-account Portal reload → plain `/me`.** `initializeAuth()` restores an + account without a new redirect login event. `AuthProvider` calls + `api.getCurrentUser({ signal })`, producing GET `/users/me`. + That route calls `resolveExisting()`, with the hit/miss/outage behavior above. + It never refreshes profile or `lastLoginAt`; `user_not_enrolled` offers explicit + sign-in rather than silently switching to enrollment. + +#### Failure contract + +| Condition | Response | +| --- | --- | +| Present but empty, malformed, or unsupported `Authorization` header on a non-public route with auth configured | `401`, `code: "invalid_token"`, before token verification or access lookup; never anonymous fallback. Only an absent header preserves the no-token anonymous rollout. | +| Invalid/expired IdP token | `401`, preserving verifier codes, **before Redis/Mongo access**. | +| No verified identity on `/users/me` | `401`; no enrollment or warming. | +| Missing stored user | `403`, `code: "user_not_enrolled"`; never anonymous fallback. | +| Disabled stored user | `403`, `code: "user_disabled"`; no active cache write. | +| Reserved `system` or invalid principal | `401`, `code: "invalid_principal"`; never admitted/cached. | +| Invalid `login` query | `400`; no writes. | +| JWKS unavailable or required auth service uninitialized | `503`. | +| Redis unavailable, required Mongo operation succeeds | Continue with Mongo result; log cache failure. | +| Required Mongo operation unavailable | `503`; never grant/anonymous fallback. | +| Unexpected implementation/database error | Logged centralized `500` path, not catch-all `503`. | + +#### OpenAPI authentication metadata (implemented) + +Both `GET` and `POST /api/v1/users/me` declare the HTTP bearer scheme `bearerAuth` +in OpenAPI. Swagger UI's **Authorize** control accepts the unchanged IdP access token +without its `Bearer` prefix, not an ID token or a Scope-issued token. GET retains its +documented `400`/`401`/`403`/`503` responses; POST documents +`401`/`403`/`503` and a `200` current-user response. + +`apiRoute()` forwards optional `security` metadata only. There is no global +OpenAPI security requirement, and other operations retain their existing +anonymous rollout. This does not implement the deferred authorization guards +below. + +### 4. Deferred: route-level authorization Extend `ApiRouteConfig` (the `apiRoute()` helper) with optional fields so authz is declarative and shows up in the OpenAPI spec (`security` + `401`/`403` responses): @@ -376,7 +537,7 @@ wildcard/subsumption semantics in §2** — e.g. `scope/*:admin` matches, but > Roles still exist as the *authoring* convenience (you assign a user a role, which > expands to permissions). Routes are authored against permissions. -### 5. Data ownership & scoping +### 5. Deferred: data ownership & scoping The model rests on two ideas, kept deliberately small for v1: @@ -505,7 +666,12 @@ that must be settled before that work is scheduled. **v1 ships `ownerId` + fields above are documented intent, not implemented yet. -### 6. Service-to-service auth +### 6. Deferred: service-to-service auth + +> This section preserves the future ownership/RBAC design. None of its Scope-issued +> JWTs, signing keys, service credentials, minting endpoints, or revocation lists are +> introduced by the current explicit-login/cache implementation. Human clients keep +> presenting the IdP bearer; there is no human token-exchange endpoint. Internal callers (scheduler, report-generator, future internal API consumers) and any worker that reaches the API authenticate with a **service principal**, not a user. @@ -563,10 +729,11 @@ downstream service applies the same ownership scoping). When that's required: - **Permissions are NOT baked into the token (revocation must work).** Embedding a `permissions` array means a disabled user, a demoted admin, or a removed permission keeps working until `exp` — revocation becomes theoretical. So: - - **Preferred: carry `sub` only.** Downstream **re-resolves** role + effective - permissions **and `disabledAt`** from the live `users` record (the same JIT/lookup path - as the IdP flow), so revocation and demotion take effect immediately. The - `disabledAt → 403` check applies on the **internal-JWT path**, not just the IdP path. + - **Preferred: carry `sub` only.** The future downstream path must resolve role, + effective permissions, and `disabledAt` without JIT. Its invalidation/revocation + policy must be defined before this feature ships. The current human IdP path uses + a fixed-TTL active-user cache (§3), **not a live Mongo check on every request**; + a live downstream lookup must not be mistaken for an existing human-path guarantee. - **If permissions must be carried** (e.g. downstream can't reach Mongo), bound the staleness explicitly: a hard **`exp` ceiling of ≤ 5 minutes** (not a vague "minutes") **and** a **`jti` revocation list** the verifier consults, so a token can be killed @@ -604,10 +771,22 @@ downstream service applies the same ownership scoping). When that's required: > on-behalf-of token, or the token is handed to it directly on the queue message (short > `exp`). Either way the IdP is never involved. +### 7. CLI authentication — current bearer compatibility, deferred interactive UX + +Today `apiFetch()` attaches the caller's raw IdP `SCOPE_TOKEN`. Already-enrolled +users keep using it unchanged. A new identity must intentionally call +`POST /api/v1/users/me` with that bearer before ordinary authenticated commands; +GET `/me` returns `403 user_not_enrolled` rather than auto-enrolling. +No CLI code is added by this milestone. For the explicit enrollment request, use +`Cache-Control: no-store`, never prefetch it, and keep tokens out of logs. + +The remaining interactive CLI design is **deferred**: + - New command group `scope auth`: - `scope auth login` — Entra **device-code flow** via `@azure/msal-node` `PublicClientApplication.acquireTokenByDeviceCode`. Provides a - **great login UX** (see below). + **great login UX** (see below). After successful device-code authentication, its + first Scope API call must be POST `/users/me`; token refresh is not enrollment. - `scope auth logout` — clears the cached tokens from the `SecretStore` (OS keychain). - `scope auth status` / `scope auth whoami` — shows the signed-in identity + role (calls `GET /api/v1/users/me`). @@ -662,7 +841,8 @@ downstream service applies the same ownership scoping). When that's required: > [!IMPORTANT] > **Large cross-cutting refactor — centralized `apiFetch()` on top of [`ky`](https://github.com/sindresorhus/ky).** -> The CLI today calls `fetch` directly in ~every command +> **Historical refactor rationale (transport delivered; see subtask 7).** The CLI +> previously called `fetch` directly in ~every command > ([apps/cli/src/commands/run.ts](../../apps/cli/src/commands/run.ts) > alone has a dozen call sites, plus `run-get-action.ts`, and every other command > module), and the **Portal** has its own ad-hoc `fetch` paths in @@ -707,30 +887,57 @@ downstream service applies the same ownership scoping). When that's required: ### 8. Portal authentication -- Add `@azure/msal-browser` + `@azure/msal-react`. Wrap the app in `` in - [apps/portal/src/main.tsx](../../apps/portal/src/main.tsx). -- **Auth Code + PKCE** redirect flow. MSAL config (authority, clientId, scopes, audience) - is **hardcoded** in the Portal build for now — there is **no** `GET /api/v1/auth/config` - fetch. Retargeting the IdP is a config change in the Portal (mirroring the CLI). -- `` (or a route guard) gates the app; unauthenticated - users are redirected to login. -- The `request()` helper in [apps/portal/src/lib/api.ts](../../apps/portal/src/lib/api.ts) - acquires a token silently (`acquireTokenSilent`, falling back to redirect) and sets - the `Authorization` header. On `401`, it triggers re-auth. -- **Permission-aware UI**: an `AuthContext` exposes `{ user, role, permissions }` (from - `GET /api/v1/users/me`). Nav items, the Tokens/Accounts/Admin/Users pages, and - catalog-write actions are shown/enabled based on **permissions** (e.g. - `hasPermission("scope/user:admin")`), not hardcoded role names. (UI gating is - convenience only; the API is the enforcement boundary.) -- **No dev role switcher.** Dev mode is removed (§1); the Portal always authenticates - against a real IdP. There is no `X-Dev-User` toggle and no synthetic-principal - bypass. The per-environment `SCOPE_AUTH_ENABLED` (integration/production, runtime) - and `VITE_AUTH_ENABLED_LOCAL` (local dev, build-time) controls (subtask 10) are - **not** such a bypass: they turn the auth **feature** off wholesale (no gate, no - token, **no fabricated principal**) as a rollout gate while the API lacks token - verification — they never authenticate a request as a user. - -### 9. SSE / log streaming +- MSAL (`@azure/msal-browser` + `@azure/msal-react`) uses **Auth Code + PKCE** + redirect login. `MsalProvider` and `AuthProvider` are composed in + [main.tsx](../../apps/portal/src/main.tsx). IdP settings are build-time + `VITE_AUTH_*` configuration; there is no `/api/v1/auth/config` fetch. +- `initializeAuth()` distinguishes an account-bound completed callback from a + cached-account reload. `AuthProvider` alone owns the Scope handshake via + `api.enrollCurrentUser({ signal })` or `api.getCurrentUser({ signal })`: + **callback → POST `/users/me`**; **cached account → GET `/users/me`**. + A silent token refresh is not a new login. +- The context exposes signed-out/resolving/ready/denied/error states. **MSAL account + presence is not application authentication.** In `main.tsx`, `RequireAuth` wraps + all eager API-query providers and `App`, including its version/favicon request. + `FeatureFlagProvider` also gates its query explicitly on Scope readiness. No + signed-out exception may let feature flags race the handshake. + Auth-disabled mode preserves anonymous behavior without a handshake. +- `AuthContext` owns the returned Scope UUID and singular `role`. MSAL account, + username, and subject may remain display fallbacks, but they do not replace the + API's identity or supply permissions. +- `useAccount()` observes active-account-only changes; `getAccountKey()` includes + home/local account IDs, tenant, and environment. In-flight handshakes are + deduplicated per account/login event. Re-render, + StrictMode, focus, or query retries must not repeat a completed enrollment POST + request. Consume a callback event only after success; explicit retries retain + it. A plain `/me` `user_not_enrolled` denial shows a sign-in action, not automatic JIT. +- `wireApiAuth()` and the existing shared `api-client` interceptor remain the only + bearer transport. The token provider **must not await the handshake** it is + supplying a token for; ordering comes from provider/query gating, avoiding a + deadlock or a second token/retry implementation. +- Errors preserve HTTP status and stable API `code`. Keep the existing one-time + `401` token-refresh retry, then interactive redirect. `403` and `503` do not + automatically reauthenticate: show denial/sign-in or retry/sign-out actions. + Natural network retries in `ky` are disabled so a lost response cannot + automatically replay an already-completed enrollment POST's writes. +- Logout/account change uses `clearSession()` to abort the handshake and shared API + session signal (`setApiSessionSignal()`), cancel/clear QueryClient data, and + discard the abandoned callback event. It ignores late + results, clears Scope state, and prevents another account's query data from + appearing. It does **not** delete the shared Redis cache entry. +- No new bearer store, Scope session token, or separate sign-in endpoint is + introduced. `/users/me` uses client/server no-store; the enrollment POST is never + prefetched or polled. +- `SCOPE_AUTH_ENABLED` (integration/production runtime) and + `VITE_AUTH_ENABLED_LOCAL` (local build-time) disable the Portal feature wholesale + (no MSAL, gate, token, or fabricated principal). They do not lock down the API; + existing anonymous API rollout policy still applies. + +**Deferred:** permission-aware navigation and admin/catalog-write gating based on +effective permissions, self-scoped runs, and user-management UI belong to RBAC. +The current Scope `role` is authoritative metadata, not evidence those features ship. + +### 9. Deferred: SSE / log streaming authorization `EventSource` cannot set custom headers, so **`fetch`-based streaming (`ReadableStream`) is the preferred transport** for the live-log SSE endpoints in both Portal and CLI: it can @@ -740,7 +947,7 @@ cannot use fetch-streaming) and, when used, the token **must be short-lived and scoped** to the stream, and **must never be logged** (scrubbed at the proxy and app layers). The SSE endpoint applies the same `readScope` access check on the parent run. -### 10. Where secrets are stored +### 10. Deferred RBAC/internal-auth secret storage We classify the auth-related material and store each appropriately. The guiding rule: **public verification material is fetched, not stored; real secrets go to Key Vault via @@ -755,34 +962,37 @@ the existing External Secrets pipeline.** | **Portal tokens** | **Yes** | Browser memory via MSAL (session/`localStorage` per MSAL cache config) | No tokens in app code or repo. | | **App user records / roles / permissions** | No (PII) | MongoDB `users` collection | Identity + authorization data, not credentials. | -Local dev (`docker:up:infra` + Lowkey Vault) follows the same shape: per-service -`INTERNAL_API_KEY_` values come from `.env`, and IdP verification points at a real -IdP (or the future **Entra ID local emulator**, §1) — there is **no** auth-bypass mode, so -local dev exercises the same verification path as production. New env vars are documented in -[ENV_VARIABLES.md](../../ENV_VARIABLES.md) and wired through the API's -External Secrets / SecretStore manifests. +When the deferred service-auth feature ships, local dev (`docker:up:infra` + +Lowkey Vault) should follow this same secret-storage shape. Current explicit-login +auth uses a real IdP or the Entra local emulator (§1), existing Redis configuration, +and `AUTH_USER_CACHE_TTL_SECONDS`; **it requires none of these future internal keys**. +Deployment/External Secrets overlays outside this repository must be updated and +verified separately when those deferred credentials are introduced. --- ## Subtasks -> Ordered. Each `auth?`/`permissions` default keeps unlisted routes authenticated. -> Tests are Vitest, co-located as `.test.ts`. +> This roadmap mixes delivered foundations with **deferred RBAC work**, marked below. +> `auth?`/permission defaults describe future guards, not today's anonymous rollout. +> Tests are Vitest, co-located as `.test.ts`. The explicit-login/cache change +> does not add a collection/migration or require the deferred permission model. -1. ⬜ **Auth abstraction in `shared`** — Add `packages/shared/src/auth/` with - `AuthProvider`, `VerifiedIdentity`, `AuthClientConfig` (the **hardcoded-by-clients** - config shape), `AuthError`, the `Permission`/`Action` types, `UserRole` + +1. 🟡 **Auth abstraction in `shared`** — Delivered: `AuthProvider`, `VerifiedIdentity`, + `AuthClientConfig`, `AuthError`, `UserDocument`, `EntraIdAuthProvider`, and claims + profile enrichment. **Deferred**: extend `packages/shared/src/auth/` with + the `Permission`/`Action` types, `UserRole` + `PrincipalRole`, the `ROLE_PERMISSIONS` map + `hasPermission()` (with the **specified - wildcard/subsumption semantics**, §2), and `EntraIdAuthProvider` (JWKS verify via - `jose`, extracting `oid`/`tid`/`email_verified`). Add `UserDocument` schema (role + - `permissionsAdd`/`permissionsRemove` + `idpTenant`/`emailVerified`). Export from - `shared`. **Done when** unit tests verify a signed JWT (mocked JWKS) passes, - tampered/expired/wrong-aud tokens throw, and `hasPermission` resolves role bundles + + wildcard/subsumption semantics**, §2), and `UserDocument` permission overrides + (`permissionsAdd`/`permissionsRemove`). Export from `shared`. + **Done when** existing signed-JWT rejection tests remain green and `hasPermission` + resolves role bundles + wildcards correctly **including the mandatory negative cases** (e.g. `scope/run:write` not satisfied by `scope/criteria:admin` or by `scope/run:read`). -2. ⬜ **`users` collection + migration** — New migration: create `users` with unique - `(idp, idpTenant, idpSubject)` index + `email` index; add `ownerId` (a **Scope User +2. 🟡 **`users` collection + ownership migration** — Delivered: `users` with unique + `(idp, idpTenant, idpSubject)` index and read-only `findByIdentity()`; cache + resolution reuses this index. **Deferred**: add `ownerId` (a **Scope User ID** = `users._id`) **and `visibility` (`"private"|"shared"`, default `private`)** to `requests`/`runs` and the user-owned catalog collections, with indexes (incl. a `visibility`+`ownerId` index for `readScope`); backfill `ownerId = "system"` (a reserved @@ -790,17 +1000,15 @@ External Secrets / SecretStore manifests. Decisions). **Done when** `pnpm migrate:up`/`down` succeed locally and indexes exist. Depends on 1. -3. ⬜ **API authn middleware + bootstrap** — Instantiate `AuthProvider` from env; - mount global middleware; JIT-provision users; **bootstrap admins matched on - `(idp, idpTenant, idpSubject)` within `AUTH_BOOTSTRAP_TENANTS`** (never email), - promote-only; resolve effective permissions; `anonymous` principal for no-token; - **per-service principal recognition** (per-service JWT or `INTERNAL_API_KEY_`, - narrow perms). **No dev/bypass principals and no `AUTH_ENABLED`.** Enforce - `disabledAt → 403` on **both** the IdP and internal-JWT paths, and **never** assign - `req.user.id = "system"` to a live caller. Add `getUser(req)`/`hasPermission` + typed - `req.user`. **Done when** protected routes return `401` anonymous / `200` with a valid - token, a disabled user is rejected mid-session, and a per-service credential - authenticates with only its granted permissions. Depends on 1, 2. +3. ✅ **Explicit-login API authn + access cache** — Verify IdP JWT before cache, + register `/users/me` before existing-user middleware, and share + `UserAccessResolver`. Only POST `/users/me` enrolls/refreshes/bootstraps; every + GET is read-only. Promotion uses exact identity + tenant allowlists and remains + promote-only; email storage still requires verification. Normal requests use + fixed-TTL Redis, then read-only indexed Mongo fallback. Missing/disabled users + receive distinct `403`s; `system` receives `401`. + Preserve anonymous/public rollout. **Deferred**: permissions, service/internal-JWT + verification, and mutation-driven eviction endpoints. 4. ⬜ **Route authz in `apiRoute()`** — Add `auth`/`permissions` to `ApiRouteConfig`, per-route permission guard (wildcard-aware, per §2 semantics incl. negative cases), and @@ -823,11 +1031,13 @@ External Secrets / SecretStore manifests. item (`403` on write), and a valid deep link grants read-only access (5b); `admin` sees all. **5b must ship with subtask 11.** Depends on 3 (5a) / 3, 4, 11 (5b). -6. ⬜ **User endpoints** — `GET /api/v1/users/me` (self; returns role + effective - permissions), `GET/PATCH /api/v1/users` + `/:id/role` and permission overrides - (requires `scope/user:admin`, soft-disable). **No `/api/v1/auth/config` endpoint** — - client config is hardcoded (§7/§8). **Done when** endpoints return correct data and - role/permission changes take effect on next request. Depends on 3, 4. +6. 🟡 **User endpoints** — Delivered: `/users/me` query/method/no-store contract (§3), + returning Scope UUID, singular role, and optional profile/provider fields, not + effective permissions. **Deferred**: `GET/PATCH /api/v1/users`, `/:id/role`, + permission overrides, and soft-disable administration (`scope/user:admin`). + Mutation endpoints must evict the namespaced active-user cache entry; until they + exist, DB-only role/disable edits can remain stale until TTL expiry. + **No `/api/v1/auth/config` or `/auth/login` endpoint.** 7. ✅ **Centralized `apiFetch()` refactor on `ky`** *(large, cross-cutting)* — Introduce a single `apiFetch()` wrapper in the CLI — **built on [`ky`](https://github.com/sindresorhus/ky)** as the @@ -851,7 +1061,8 @@ External Secrets / SecretStore manifests. > mocks don't need `clone()`). Seams for subtasks 8/9: `setTokenProvider`, `setReauthHandler`, > `setApiLogSink`, `resetApiClient`; error shaping via `ApiError` + `readApiError(response)`. **Portal** — > [apps/portal/src/lib/api-client.ts](../../apps/portal/src/lib/api-client.ts) exports a shared `ky` - > instance (`apiClient`) with the same config + a `setApiTokenProvider` auth seam; `lib/api.ts` + > instance (`apiClient`) with a `setApiTokenProvider` auth seam and one forced `401` + > retry (natural network/status retries disabled to avoid login replay); `lib/api.ts` > `request()`/`batchArchive` and `hooks/useHarExtraction.ts` now route through it (`recordServerDate` > stays in the facade). The **only** remaining direct `fetch` calls are: the wrappers themselves, the > CLI's external GitHub Releases poll in `utils/update-check.ts` (its own `token` auth — must never @@ -878,11 +1089,11 @@ External Secrets / SecretStore manifests. `--debug-zip` produces a zip, and a redaction test asserts no token/refresh-token/ service key ever appears in the output. Depends on 7. -10. 🟡 **Portal auth** — `@azure/msal-react`; `MsalProvider`; route guard; token - injection in `api.ts`; `AuthContext` with `useMe()`; permission-aware nav/pages; - **hardcoded IdP config** (no `/auth/config`). **No dev role switcher.** **Done when** - unauthenticated users are redirected to login, runs list is self-scoped, and admin UI - is hidden for `user`. Depends on 6. +10. 🟡 **Portal auth** — Delivered: MSAL bearer transport plus one `AuthProvider` + handshake, callback/reload distinction, query gating, account-bound deduplication/ + cancellation, and API-authoritative Scope identity/role (§8). **Deferred**: + permission-aware nav/pages and self-scoped runs. IdP settings remain build-time + `VITE_AUTH_*`; no `/auth/config`, Scope token store, or dev role switcher. > **MVP shipped (authentication only).** Delivered so far: MSAL sign-in > (auth-code + PKCE redirect), `MsalProvider` + `AuthProvider`, a `RequireAuth` @@ -892,6 +1103,13 @@ External Secrets / SecretStore manifests. > `setApiTokenProvider`/`setReauthHandler` seams). IdP config is build-time > (`VITE_AUTH_*`, see [ENV_VARIABLES.md](../../ENV_VARIABLES.md)) defaulting to > the `entra-local` emulator for local dev. + > Docker's `builder` stage accepts these public settings as build arguments: + > Compose forwards them through `portal.build.args`, and CI forwards the + > same-named GitHub Actions configuration variables. They are embedded by + > Vite, not read from the final nginx container's environment. The dev image + > continues to read them from the Vite process environment. Changing the + > IdP requires rebuilding the image; promoting one image preserves its IdP + > settings. Empty optional redirect settings retain the current Portal origin. > > **Feature toggle (important).** Portal auth is **on by default (secure by > default)** but can be turned off per environment via **three independent @@ -903,32 +1121,41 @@ External Secrets / SecretStore manifests. > written into `/config.js` by `apps/portal/docker-entrypoint.sh` (same > mechanism as `SCOPE_DOCS_BASE_URL`). When off, the Portal skips MSAL entirely > — no sign-in gate, no account menu, no `Authorization` header. This is a - > **rollout gate**, used to keep auth off in an environment **until its API - > verifies tokens** (the API does not yet). It is **not** a dev auth-bypass: it + > **rollout gate**, controlled per deployment while compatible API/Portal versions + > are deployed. The API in this branch verifies tokens. It is **not** a dev auth-bypass: it > disables the feature wholesale and fabricates **no** principal (contrast the > forbidden `X-Dev-User`/synthetic-user bypass in §8 and the security matrix). - > Since the API is the enforcement boundary, disabling a control once the API - > verifies tokens simply means the Portal sends no token and the API rejects the - > request — it cannot grant access. Resolution precedence: runtime + > Disabling a control means the Portal sends no token; `/users/me` rejects that + > request, while other routes retain existing anonymous rollout behavior. + > It never fabricates an authenticated user. Resolution precedence: runtime > `authEnabled` (int/prod) wins; else `VITE_AUTH_ENABLED_LOCAL` (local dev); else > default enabled. See [ENV_VARIABLES.md](../../ENV_VARIABLES.md) "Feature > toggle". > > **One-command local dev.** Any `pnpm docker:dev:*` script that starts the > Portal brings up the `entra-local` emulator (compose `auth` profile) over - > HTTPS with an mkcert-issued, locally-trusted `localhost` cert - > (`scripts/ensure-dev-certs.sh`), and auto-registers the per-worktree Portal + > HTTPS with an mkcert-issued certificate covering both `localhost` and the + > Compose hostname `entra-local` (`scripts/ensure-dev-certs.sh`), and + > auto-registers the per-worktree Portal > redirect URI via a one-shot `entra-local-init` service. MSAL requires the > authority to be served over HTTPS (it rejects non-HTTPS authorities with > `authority_uri_insecure`), hence the mkcert TLS setup rather than plain HTTP. + > The provisioning script renews older localhost-only or expiring certificates + > and exports only the public CA. Compose shares that CA in a separate, + > read-only volume with the API and redirect-registration helper via + > `NODE_EXTRA_CA_CERTS`; the emulator health check trusts it too. The API + > never receives the emulator private key, and no auth-related HTTPS call + > disables certificate verification. The CA initializer remains optional + > without the `auth` profile; recreate clients after rotating the CA because + > Node loads extra CAs at startup. > The only interactive step is a one-time `mkcert -install` password prompt. > See [ENV_VARIABLES.md](../../ENV_VARIABLES.md) "Local dev setup (entra-local)". > - > **Deferred (needs subtask 6 + API-side authn):** because the API does not - > verify tokens yet, enforcement is **client-side only** and identity shown in - > the UI comes from **MSAL account token claims**, not `GET /api/v1/users/me` - > (no `useMe()` yet). Self-scoped runs lists and permission-aware nav / admin-UI - > hiding are authorization concerns and are **out of scope for this MVP**. + > **Scope handshake delivered:** after callback the first Scope API request is + > POST `/users/me`; a cached-account reload first uses GET `/users/me`. + > `AuthContext` does not expose application-authenticated identity until that + > succeeds; feature flags and route queries wait too. MSAL-only identity display + > is no longer the contract. Self-scoping and permission-aware UI remain deferred. 11. ⬜ **Service-to-service auth** *(co-requisite of subtask 5)* — **Per-service** principal recognition: per-service JWT (verified with the Scope public key) **or** @@ -963,7 +1190,13 @@ External Secrets / SecretStore manifests. `readScope` check on the parent run. **Done when** log streaming works authenticated and is owner/shared-scoped. Depends on 5, 8, 10. -14. ⬜ **Deployment & config** — Add auth env vars to +14. 🟡 **Deployment & config** — Current cache config is + `AUTH_USER_CACHE_TTL_SECONDS` plus existing Redis settings; isolate independently + backed deployments by Mongo database namespace. Validate the deployed API/Portal + pairing and enrollment handshake before enabling Portal auth. No new signing + secret is needed. External deployment overlays must be checked separately, not + assumed updated by this branch. **Deferred RBAC/internal-auth deployment**: add + the corresponding auth env vars to [ENV_VARIABLES.md](../../ENV_VARIABLES.md) and the API/portal K8s manifests (deployment, configmap), **External Secrets** for the **per-service `INTERNAL_API_KEY_`** values + the **internal JWT @@ -974,7 +1207,9 @@ External Secrets / SecretStore manifests. at the registration** — **no Entra App Roles**. **Done when** the int overlay deploys with auth enforced (no bypass mode exists). Depends on 3–13. -15. ⬜ **Docs** — Update [AGENTS.md](../../AGENTS.md), the `scope-api` / +15. 🟡 **Docs** — Explicit-login/cache guidance is updated in [AGENTS.md](../../AGENTS.md), + [ENV_VARIABLES.md](../../ENV_VARIABLES.md), this spec, and the API/CLI skills. + **Deferred:** update the `scope-api` / `scope-cli` skills, [docs/architecture/overview.md](overview.md), and [docs/architecture/app-design.md](app-design.md) to reflect auth/RBAC, the `users` model, `ownerId`, and the `security_audit`/metrics surface. **Done when** docs @@ -983,9 +1218,11 @@ External Secrets / SecretStore manifests. --- -## Implementation Plan (phased) +## Deferred RBAC rollout (historical phase numbering) -The work is sequenced so that **authenticating the user and stamping `ownerId` on +The explicit-login/cache slice of Phase 1 is implemented without ownership changes, +CLI interactive login, or full API lockdown. The remaining work is sequenced so that +**authenticating the user and stamping `ownerId` on created items comes first**; **enforcing permissions/ownership comes second**. This lets us ship identity + provenance early (low risk — nothing is locked down yet), then turn on enforcement once data is correctly attributed and the report-generator is ready. @@ -995,21 +1232,21 @@ Auth & RBAC rollout │ ├── Phase 0 — Foundations (no behavior change) [subtasks 1, 2] │ ├── shared/auth: AuthProvider, EntraIdAuthProvider, Permission, ROLE_PERMISSIONS -│ ├── users collection + (idp, idpTenant, idpSubject) / email indexes +│ ├── users collection + unique identity index; non-unique email index (sparse on native MongoDB only) │ └── add ownerId + visibility to requests/runs/catalog (+ indexes); backfill "system" sentinel │ └── Gate: migrations up/down clean; shared unit tests green │ ├── Phase 1 — Authenticate the user (IDENTITY FIRST) [subtasks 3, 6, 7, 8, 10] -│ │ Goal: every human caller is identified; NO enforcement yet. (THE milestone.) +│ │ Current slice: explicit enrollment + active-user resolution; no route RBAC. │ ├── API authn middleware (verify token → req.user) [3] -│ │ • always-verify (NO bypass mode); anonymous principal = zero perms -│ │ • JIT-provision users; bootstrap admins by (idp,tenant,subject) -│ │ • permissions resolved & attached, but NOT yet enforced on routes -│ ├── GET /users/me (auth config is hardcoded, no endpoint) [6, partial] -│ ├── CLI: apiFetch() refactor + `scope auth` login/SecretStore [7, 8] -│ └── Portal: MsalProvider + login + token injection [10] -│ └── Gate: logged-in identity flows end-to-end on CLI + Portal; -│ app still behaves as today for everyone +│ │ • verify every configured non-public bearer; anonymous rollout retained +│ │ • POST /users/me ONLY: JIT/profile/lastLoginAt/bootstrap +│ │ • ordinary requests: verify → Redis → indexed Mongo fallback (no writes) +│ ├── GET /users/me: Scope UUID + singular role; no permission expansion [6, partial] +│ ├── CLI: apiFetch() delivered; interactive login/SecretStore deferred [7, 8] +│ └── Portal: callback POST / cached-account GET handshake [10] +│ └── Gate: queries wait for Scope identity; new bearer identities enroll explicitly; +│ anonymous/public rollout remains unchanged │ ├── Phase 2 — Stamp ownerId on created items (PROVENANCE) [subtask 5a] │ │ Goal: every NEW item records its owner + visibility; still no read/write blocking. @@ -1042,15 +1279,17 @@ Auth & RBAC rollout ``` **Why this order** -- **Phases 0–2 are non-breaking**: they add identity and `ownerId` provenance without - denying anyone access, so they can merge and run in production safely and incrementally. +- **Current compatibility boundary:** existing enrolled IdP-bearer callers and + anonymous/public rollout stay supported. New bearer identities must explicitly + enroll; disabled users are denied when observed after login or cache expiry. + This is not a promise that every previously authenticated request still succeeds. - **The hard cutover is Phase 3** (ownership enforcement + service-to-service auth shipped together). Doing identity + provenance first means that by the time we flip enforcement on, runs are already correctly attributed and the report-generator path is ready — avoiding `404`s and mis-scoped data. - **Permission enforcement (Phase 4) is deliberately second**, per the priority: identity - and ownership are the must-haves; fine-grained RBAC builds on the already-attached - `permissions`. + and ownership are the must-haves; fine-grained RBAC must add permission resolution + rather than assume the current singular `role` is already a permission bundle. > Subtask 5 is split for sequencing: **5a** (stamp `ownerId` + `visibility` on create, > Phase 2) and **5b** (apply `readScope`/`writeScope` + deep links to reads/writes, @@ -1060,21 +1299,69 @@ Auth & RBAC rollout ## Acceptance Scenarios -### Setup +### Current explicit-login/cache contract + +Use configured Entra/entra-local, the existing users identity index, and isolated +Redis test data. Do not stop a shared Redis service to simulate outages. + +The opt-in +[`auth-flow.integration.test.ts`](../../apps/api/src/auth/auth-flow.integration.test.ts) +exercises real RS256 verification with locally generated keys and the registered +Express routes backed by real MongoDB/Redis. Start **isolated test infrastructure**, +set `AUTH_TEST_MONGO_URI` to its Mongo connection URI and `AUTH_TEST_REDIS_PORT` to +its unauthenticated loopback Redis port, then run from the repository root: + +```bash +pnpm exec vitest run --config vitest.integration.config.ts apps/api/src/auth/auth-flow.integration.test.ts +``` + +Both variables are required to opt in; a **skipped suite is not validation**. +The suite creates/drops its own randomized Mongo database and cleans only its +namespaced Redis keys. It covers enrollment, read-only hits/expiry, token rejection +before cache access, disabled-user denial, and unavailable-cache Mongo fallback. +It does not replace the Portal callback/order checks or live IdP/JWKS outage tests. + +| Scenario | Expected result | +| --- | --- | +| Fresh Portal callback | First Scope API call is `POST /api/v1/users/me`; feature flags and all other eager queries wait for success. | +| First enrollment | Scope UUID/default `user` role; verified profile handling; exact identity + tenant bootstrap independent of email verification; explicit `lastLoginAt` write; active cache warmed. | +| Login on active cache hit | Still bypasses the read cache and upserts, returning/warming the latest stored role. | +| Ordinary/plain `/me` hit | Verify token first; no Mongo operation, profile enrichment, bootstrap promotion, or `lastLoginAt` write; TTL not extended. | +| Miss/expiry | Exact identity Mongo read, validate/warm; missing user is `403 user_not_enrolled`, never JIT. | +| Tenant/provider/database isolation | Same `oid` in a different tenant/provider or independently namespaced database cannot reuse an entry. | +| Bad cache payload | Malformed/version-mismatched/identity-mismatched data is a logged, evicted miss, not an authenticated user. | +| Invalid/expired token with warm cache | `401` before all cache/DB operations. | +| Query/method boundary | Every GET is read-only; invalid/repeated/structured login values are `400`; only POST enrolls, while HEAD and other routes never do. | +| Disabled/reserved identity | `403 user_disabled` / `401 invalid_principal`; no negative cache, discovered old active entry evicted best-effort. | +| Disabled explicit login ordering | Existing upsert may refresh profile/timestamps before disabled validation returns `403`; still never caches/admit the user. | +| TTL | Unset → 300; positive safe integer override accepted; invalid values rejected; hits non-sliding; DB-only role/disable changes visible after expiry. | +| Redis outage/recovery | Bounded operations and rate-limited logs; Mongo fallback; successful DB result survives cache-write failure; no offline write replay. | +| Required Mongo/JWKS unavailable | `503`; never anonymous/success fallback. Unexpected implementation/database errors remain `500`. | +| Cached-account reload | Plain `/users/me` first, no `lastLoginAt` change; missing enrollment requires explicit sign-in. | +| Portal races/errors | Deduplicate same-account/login event; explicit retry retains failed callback; logout/account change cancels/ignores stale work and clears account data; no `403`/`503` redirect loop. | +| Compatibility | Existing enrolled raw IdP bearers, public probes, anonymous worker rollout, and auth-disabled Portal still work; CLI implementation unchanged. | +| HTTP cache policy | All `/users/me` responses, including errors, are no-store; client sends no-store and never prefetches login. | + +### Deferred RBAC/CLI/internal-auth setup - Start infra: `pnpm docker:up:infra`; run migrations: `pnpm migrate:up`. - Register an Entra **API app** (expose `access_as_user`) and a **public client** (device-code + SPA redirect URIs), both **multi-tenant**. **No App Roles** — roles/permissions are managed in Scope. Tenant filtering (if any) is set on the registration. -- Env: `AUTH_AUTHORITY`, `AUTH_API_CLIENT_ID`, `AUTH_CLIENT_ID`, `AUTH_SCOPES`, +- Env: `AUTH_PROVIDER=entra`, `AUTH_AUTHORITY`, `AUTH_API_CLIENT_ID`, `AUTH_SCOPES`, `AUTH_BOOTSTRAP_ADMINS=entra:/`, `AUTH_BOOTSTRAP_TENANTS=`, - `INTERNAL_API_KEY_REPORTGEN=`. (No `AUTH_ENABLED` — there is no bypass mode.) - The CLI/Portal carry the **hardcoded** IdP config (no `/auth/config`). + `AUTH_USER_CACHE_TTL_SECONDS=300`. Only the **deferred** service-auth scenarios need + `INTERNAL_API_KEY_REPORTGEN` or internal signing material. No `AUTH_ENABLED` + synthetic-principal bypass. Clients carry their own IdP configuration (no `/auth/config`). - Two test identities: `admin@…` (its `(idp,tenant,subject)` in the bootstrap list) and `user@…` (not). -### Scenarios +### Deferred RBAC/CLI/internal-auth scenarios + +These are future enforcement criteria, **not implemented acceptance claims**. In +particular, the current no-token request to `/requests` is not globally locked down. +Any future "next request" role/disable guarantee requires mutation-driven cache eviction. | # | Scenario | Steps | Expected Result | |---|----------|-------|-----------------| @@ -1085,7 +1372,7 @@ Auth & RBAC rollout | 5 | User cannot access foreign run | As `user`, `scope run get -i ` | `404` (not `403`) | | 6 | Admin sees all runs | `scope run list` as `admin` | Both runs listed | | 7 | Permission-gated route blocked | As `user`, `PATCH /api/v1/users//role` (needs `scope/user:admin`) | `403` | -| 8 | Admin manages roles | As `admin`, promote `user`→`admin`; user re-requests | New permissions effective on next request | +| 8 | Admin manages roles | As `admin`, promote `user`→`admin`; mutation evicts its access-cache key; user re-requests | New permissions effective after eviction; DB-only edits are TTL-bound | | 9 | Portal login + scoping | Open Portal as `user` | Redirected to Entra; after login, Runs list shows only own runs; admin nav hidden | | 10 | Portal admin UI | Open Portal as `admin` | Tokens/Accounts/Admin/Users pages visible; all runs listed | | 11 | Shared vs private visibility | As `user`, create one `private` and one `shared` criterion; as another `user`, list/get/edit both | Both visible & usable; **only the owner** can edit; editing the shared one as non-owner → `403`; the private one is invisible to the other user (`404` on get) | @@ -1099,7 +1386,7 @@ Auth & RBAC rollout | 19 | Audit log written | Onboard a new user, logout, regenerate a service key, mint an on-behalf-of token | `security_audit` has `user_onboarded`, `login`, `logout`, `key_regenerated`, `token_minted` rows; no secrets in `detail` | | 20 | Audit metrics exposed | `curl /metrics` after the above | `scope_auth_logins_total`, `scope_auth_onboarded_total`, `scope_auth_key_regenerations_total` counters incremented | | 21 | Read-only deep link | Owner shares a deep link to a `private` run; recipient opens it, then attempts an edit | Recipient can **view** the run read-only; any write/delete → `403`; revoking the link → subsequent view `404` | -| 22 | Revocation takes effect | Disable a user (or revoke an on-behalf-of `jti`) while a token is still within `exp` | Next request → `403`/`401` (no waiting for `exp`); covers both the IdP and internal-JWT paths | +| 22 | Mutation-driven revocation | Future disable endpoint evicts the user's active-cache key, or revoke a future internal-token `jti` | Following resolution denies access; the current DB-only disable path remains TTL-bound, not an immediate live-DB check | | 23 | No bypass mode | Set any env (`AUTH_ENABLED`, `DEV_USER`) and send `X-Dev-User` | Ignored entirely; request is still anonymous → `401`; no synthetic principal is ever created | UI checks (Portal): login redirect, loading/empty/error states on Runs list, admin-only @@ -1109,6 +1396,9 @@ nav hidden for `user`, role badge in header. Responsive at 375 / 768 / 1280 px. ## Constraints +Deferred CLI/RBAC/internal-token requirements below apply only when those features +ship; they are not dependencies or secrets introduced by explicit login/caching. + - **CosmosDB-compatible Mongo**: unique compound index `(idp, idpTenant, idpSubject)` and the new `ownerId`/`visibility` indexes must use features the Cosmos Mongo API supports (avoid partial/TTL index features not supported). Verify against existing migration patterns. @@ -1124,8 +1414,8 @@ nav hidden for `user`, role badge in header. Responsive at 375 / 768 / 1280 px. without touching call sites. - **OpenAPI parity**: every route's `auth`/`permissions` must surface in the generated spec; the snapshot test must be updated. -- **CLI ↔ Portal parity** (AGENTS.md): any auth/role capability in the Portal must - exist in the CLI. +- **CLI ↔ Portal parity** (AGENTS.md) remains a product goal. Interactive CLI auth + is deferred; current bearer compatibility and explicit enrollment are documented. - **No bypass / dev mode**: there is **no** env-toggled auth bypass. No `AUTH_ENABLED`, `DEV_USER`, or `X-Dev-User` synthetic principal exists in any environment. Local development authenticates against a real IdP; a future **Entra ID local emulator** @@ -1144,14 +1434,40 @@ nav hidden for `user`, role badge in header. Responsive at 375 / 768 / 1280 px. Mongo audit write must **fail the security-sensitive operation closed** (not silently drop the record); the retention TTL is an explicit policy decision, not an incidental default. -- **Performance**: token verification per request must be local (cached JWKS), no - network round-trip to the IdP on the hot path; user lookup is a single indexed - Mongo read (cacheable per request). +- **Performance**: every non-public authenticated request verifies the IdP signature + and claims first, usually locally with cached JWKS (fetch/rotation may require + network). An active Redis hit performs **no Mongo lookup/write**. Miss/unavailability + performs one indexed identity read and best-effort cache warming, never JIT. + Explicit login bypasses the cache read and performs its required upsert. +- **Consistency and failures**: fixed TTL bounds active-user staleness; no sliding + extension or negative cache. Redis failures fall back to Mongo, required Mongo/JWKS + outages fail `503`, and unexpected errors remain `500`. Never log/cache raw tokens. --- ## Decisions +### Current explicit-login/access decisions + +| Decision | Choice | +| --- | --- | +| Credential | IdP access token unchanged on every call, verified before any cache access; no Scope session JWT or `/auth/login`. | +| Enrollment | Only POST `/users/me`; every GET `/me` and other routes resolve existing active users. | +| Store identity | Exact `(idp, tid, oid)` lookup; Scope-owned UUID and database role returned to clients. | +| Cache isolation | `auth-user:v1::::`. | +| Expiration | `AUTH_USER_CACHE_TTL_SECONDS`, default 300 only when unset, positive safe integer, fixed/non-sliding `SET EX`. | +| Denial vs cache miss | Miss/unavailable reads Mongo; missing/disabled users are distinct `403`s, reserved `system` is `401`; no negative cache. | +| Bootstrap | Exact verified identity tuple + explicit tenant allowlist, independent of email verification, only on explicit login, promote-only. | +| Timestamp | `lastLoginAt` records the explicit upsert, not ordinary activity or trustworthy proof of an interactive callback; disabled check follows upsert. | +| Portal readiness | Callback POST `/me`, cached-account GET `/me`; API UUID/role authoritative; all queries gated and account-bound work deduplicated/cancelled. | +| Rollout | Public and anonymous behavior preserved; no full RBAC/ownership lockdown; enrolled CLI bearers remain compatible. | + +### Retained RBAC roadmap decisions + +The following records include **deferred** permission, internal-JWT, service-auth, +CLI-login, audit, and secret-storage designs; they do not change the current +credential or cache consistency contract. + | Decision | Options Considered | Choice | Rationale | |----------|-------------------|--------|-----------| | Where authorization lives | (a) Entra App Roles; (b) Scope DB; (c) Hybrid | **(b) Scope DB only** | Per feedback: **no Entra App Roles**. Portable across IdPs; IdP proves identity only. | @@ -1170,7 +1486,7 @@ nav hidden for `user`, role badge in header. Responsive at 375 / 768 / 1280 px. | Downstream user identity | Forward IdP token vs Scope-minted internal token | **Scope-minted internal JWT (`iss=scope-api`, asymmetric, public-key verified); IdP token never leaves the API.** To keep authorization from going stale, the token carries **`sub` only and permissions are re-resolved downstream** (preferred), or — if perms are embedded — `exp ≤ 5min` **and** a `jti` revocation list, **both** required. `disabledAt`/revocation is re-checked on the internal-JWT path, not just the IdP path. | Downstream stays IdP-agnostic; revocation (disable user, demote admin, drop a permission) takes effect within minutes, not at token `exp`. | | Internal-JWT staleness | Long-lived perms-in-token vs re-resolve / tight exp + jti | **Re-resolve from `sub` downstream (preferred); else `exp ≤ 5min` + `jti` denylist** | Embedding permissions makes revocation impossible until `exp`; a 5-minute ceiling plus a denylist makes "short exp" concrete and enforceable. | | Entra tenancy | Single-tenant vs multi-tenant | **Multi-tenant** (Question D): accept configured tenants, no `tid` pinning in business logic; tenant filtering at the App Registration + `AUTH_BOOTSTRAP_TENANTS` allowlist for promotion | App-registration-level control; code stays tenant-agnostic. Because `oid` is unique only **within** a tenant (and guests carry their home-tenant `oid`), the unique identity index is **`(idp, idpTenant, idpSubject)`** — `tid` is part of the key. | -| Bootstrap-admin matching | Match on email vs identity tuple | **Match on `(idp, idpTenant, idpSubject)`, require `email_verified`, restrict to `AUTH_BOOTSTRAP_TENANTS`; promotion is promote-only (removal from the list does not auto-demote)** | Entra `email`/`preferred_username` is mutable and not guaranteed verified; matching on identity + verified email + tenant allowlist closes the auto-promote-by-email-collision hole and the silent-demote-by-ConfigMap risk. | +| Bootstrap-admin matching | Match on email vs identity tuple | **Match on `(idp, idpTenant, idpSubject)`, restrict to `AUTH_BOOTSTRAP_TENANTS`; promotion is independent of email verification and promote-only (removal from the list does not auto-demote)** | Entra `email`/`preferred_username` is mutable and not guaranteed verified; exact identity + tenant matching prevents promotion by email collision without depending on a nonstandard workforce email-verification claim. Promote-only behavior prevents silent demotion by a ConfigMap edit. | | Security audit | None vs log-only vs Mongo + metrics | **Append-only `security_audit` in MongoDB + Prometheus counters** (Question F) for login/logout/onboarding, key-regeneration, **token minting**, **service-key cross-user reads**, and **permission overrides** | Durable forensic record + alerting; single `recordSecurityEvent()` helper; a failed audit write **fails the operation closed**; retention TTL is an explicit policy choice; no secrets in audit. | | Secret storage | Env-only vs Key Vault + ESO | **Key Vault → External Secrets** for per-service `INTERNAL_API_KEY_`/client secret; JWKS fetched (not stored); **CLI tokens via a Scope-owned `SecretStore` backed by `cross-keychain`** (`0600` fallback) | Matches existing `mongo-secrets`/`redis-secrets` pattern; public keys are not secrets; the `SecretStore` wrapper replaces unmaintained `keytar` and isolates the backing library. | | CLI login UX | Print URL+code only vs assisted | **Clipboard copy + browser auto-open, manual fallback always shown** | Fast happy path, still works headless/SSH. | @@ -1229,7 +1545,7 @@ promotes)? ### B. Sharing, groups & projects -**Question B**: v1 **already ships** two-level visibility (`private`/`shared`) and +**Question B**: the **deferred RBAC v1** plans two-level visibility (`private`/`shared`) and read-only **deep links** (§5). What remains future is **groups/projects** and **per-user/per-group ACLs**. §5 ("Future-proofing") reserves the data shape and routes all scoping through one `readScope`/`writeScope` chokepoint so this is additive. Decisions @@ -1254,16 +1570,18 @@ to settle **before** scheduling that work: v1 implements `private`/`shared` + deep links and reserves the remaining optional fields; **none** of the group/ACL machinery ships yet. -### C. Admin bootstrap — **decided** +### C. Admin bootstrap — **implemented** **Decision** *(confirmed)*: `AUTH_BOOTSTRAP_ADMINS` is the **sole** bootstrap mechanism for seeding the first admin, but it is matched on the **identity tuple `(idp, idpTenant, idpSubject)`** — **not** on email, which is mutable and not guaranteed -verified. The matched login must also have `email_verified = true` and originate from a -tenant in `AUTH_BOOTSTRAP_TENANTS`. Bootstrap is **promote-only**: removing an entry does +verified. The matched explicit POST `/users/me` request must originate from a +tenant in `AUTH_BOOTSTRAP_TENANTS`. Neither `email` nor `email_verified` is required +for promotion; verified-email storage remains a separate, unchanged policy. +Bootstrap is **promote-only**: removing an entry does **not** auto-demote an existing admin (prevents a misconfigured ConfigMap from silently -revoking access). We do **not** use Entra App Roles. All later role/permission changes go -through the admin user-management endpoints (`scope/user:admin`). +revoking access). We do **not** use Entra App Roles. Future admin user-management +endpoints (`scope/user:admin`) must invalidate the corresponding cache entry. ### D. Multi-tenant Entra — **decided** @@ -1277,8 +1595,8 @@ code. Implications: - Issuer validation must accept the multi-tenant issuer pattern (per-tenant `iss` containing the caller's `tid`); `aud` is still pinned to `AUTH_API_CLIENT_ID`. -- JWKS is resolved via OIDC discovery for the token's tenant (or the common metadata - endpoint); the key cache is keyed by `(tenant, kid)`. +- JWKS is fetched/cached by `jose` from the configured endpoint; tenant identity + still comes from verified `tid`/issuer claims, never an unverified cache key. - Identity stays unique via **`(idp, idpTenant, idpSubject)`** where `idpSubject = oid` and `idpTenant = tid`. The Entra `oid` is **stable per user per tenant** — it is **not** globally unique, and guest/B2B users carry their **home-tenant** `oid`. Including `tid` @@ -1287,7 +1605,7 @@ Implications: - No code change is needed to add/remove tenants — it's an App Registration setting (plus the `AUTH_BOOTSTRAP_TENANTS` allowlist for admin promotion). -### E. Service-to-service mechanism — **decided** +### E. Service-to-service mechanism — **decided, implementation deferred** **Decision**: Service-to-service auth uses **per-service identities**, not one global key. Each service principal gets its **own** credential — `INTERNAL_API_KEY_` or a @@ -1319,15 +1637,16 @@ This removes the Entra client-credentials option from scope; §6, the secrets ta the decisions table reflect per-service identities + the public/private internal-JWT approach. -### F. Security audit log — **decided** +### F. Security audit log — **decided, implementation deferred** **Decision**: Scope keeps a **security audit log**, **persisted in MongoDB** and **emitted to Prometheus** as metrics. Both sinks are written for every security event; Mongo is the durable record, Prometheus is for alerting/dashboards. **Events (v1, minimum)** — emitted at minimum for: -- **Login** (successful token verification → session established) and **failed login** - (token rejected). +- **Explicit login** (POST `/users/me` completes successfully) and **failed login**. + Ordinary token verification/cache hits are not login events. The endpoint invocation + is not trustworthy proof of an interactive IdP prompt. - **Logout** (explicit `scope auth logout` / Portal sign-out). - **User onboarding** (JIT provisioning of a new `users` doc on first login). - **Key regeneration** — rotation/regeneration of any `INTERNAL_API_KEY_`, the @@ -1394,10 +1713,12 @@ ownership model makes it straightforward to add later if needed. ### H. CI / non-interactive tokens **Question H**: What does non-interactive automation present? Two separable concerns: -- **System jobs** use a **per-service** `INTERNAL_API_KEY_` service principal with - narrow permissions (§6, Question E). +- **System jobs** will use a **per-service** `INTERNAL_API_KEY_` service + principal with narrow permissions when service auth ships (§6, Question E). + Existing anonymous worker rollout remains unchanged in this milestone. - **User-attributed automation / CI** uses `SCOPE_TOKEN`. **For the current milestone**, - `SCOPE_TOKEN` is a **raw bearer** (a token already obtained interactively) — this keeps + `SCOPE_TOKEN` is a **raw IdP bearer**. Existing enrolled callers are compatible; + new identities must explicitly POST `/users/me` first. This keeps the user-auth milestone unblocked. **Scope-issued PAT/API tokens** (long-lived, user-minted, revocable) delivered via the same `SCOPE_TOKEN` slot are the likely **future** answer for CI and user-attributed automation, but they are **out of scope** @@ -1405,8 +1726,13 @@ ownership model makes it straightforward to add later if needed. ### J. Unauthenticated / public mode -**Question J**: The `anonymous` principal ships with **zero** permissions and is **out of -scope** to extend in this work. A public/demo mode is a **separate, future, explicit** +The current explicit-login change **preserves existing anonymous/public rollout** +when no token is provided or auth is not configured. It neither grants a synthetic +authenticated principal nor applies the deferred global permission guards. + +**Deferred Question J**: The permission-model `anonymous` principal has **zero** +permissions and is **out of scope** to extend in this work. A public/demo mode is a +**separate, future, explicit** decision: it would be introduced as an opt-in config granting at most `scope/run:read` over **explicitly-public data only** (a separated public `ownerId`/`visibility`), and it must never be reachable by accidentally granting a permission to `anonymous` on an @@ -1422,7 +1748,10 @@ the query-param fallback) given the proxy timeouts in ## Review -> Adversarial self-review pass. Findings and resolutions: +> Retained review of the broader RBAC proposal. "Resolved" below means a design +> decision, not proof a deferred endpoint, permission guard, or internal token ships. +> Current auth/cache behavior and validation criteria are specified in §3/§8 and the +> current acceptance matrix above. 1. **Existence leak** — Returning `403` for foreign runs reveals they exist. **Resolved**: use `404` for owner-scoped single-resource fetches. @@ -1450,8 +1779,10 @@ the query-param fallback) given the proxy timeouts in **removed entirely** — no `AUTH_ENABLED`, `DEV_USER`, `X-Dev-User`, or `local-user`/`local-admin`. Local dev uses a real IdP; a future **Entra ID local emulator** (separate project) plugs in only as an IdP configuration. -8. **JWKS network on hot path** — verifying per request must not call the IdP. - **Resolved**: cached JWKS with rotation; local RS256 verification. +8. **JWKS network on hot path** — ordinary verification should use cached JWKS. + **Resolved**: local RS256 verification with key-fetch/rotation as needed. Access + caching never bypasses verification; a hit also avoids MongoDB, while a cache miss + does a read-only exact-identity lookup. 9. **CLI token security** — tokens on disk, plus reliance on the unmaintained `keytar`. **Resolved**: tokens are stored behind a Scope-owned **`SecretStore`** interface backed by **`cross-keychain`** (`0600` file only as a keyring-less fallback), silent refresh, @@ -1491,8 +1822,9 @@ the query-param fallback) given the proxy timeouts in 17. **Bootstrap-admin trusts a mutable email claim** — Entra `email`/`preferred_username` is not guaranteed verified and is mutable; in multi-tenant mode an email collision could auto-promote the wrong user, and list edits could silently demote/promote. - **Resolved**: bootstrap matches on `(idp, idpTenant, idpSubject)`, requires - `email_verified`, is restricted to `AUTH_BOOTSTRAP_TENANTS`, and is **promote-only**. + **Resolved**: bootstrap matches on the verified `(idp, idpTenant, idpSubject)`, + is restricted to `AUTH_BOOTSTRAP_TENANTS`, and is **promote-only**. + Neither email nor its verification flag influences promotion. 18. **Multi-tenant identity collision** — `(idp, idpSubject)` is **not** unique because `oid` is stable only per tenant and guests carry a home-tenant `oid`. **Resolved**: the unique index is **`(idp, idpTenant, idpSubject)`** — `tid` is part of the key. diff --git a/docs/architecture/cli-distribution.md b/docs/architecture/cli-distribution.md index 39a739791..b46566de1 100644 --- a/docs/architecture/cli-distribution.md +++ b/docs/architecture/cli-distribution.md @@ -131,6 +131,12 @@ external GitHub API with its own `token` auth and must never receive the Scope `SCOPE_TOKEN` bearer that `apiFetch()` injects. All Scope-API requests go through `apiFetch()` (see [auth-rbac.md](./auth-rbac.md) subtask 7). +`SCOPE_TOKEN` remains a raw **IdP access token**, not a Scope-issued credential. +Existing enrolled users are compatible; a new identity must intentionally call +`POST /api/v1/users/me` before ordinary authenticated commands. The enrollment +POST is no-store and must not be prefetched/polled. Interactive CLI login/keychain +support remains deferred; see [CLI authentication guidance](../../apps/cli/README.md#authentication). + `apiFetch()` is a thin facade over the [`ky`](https://github.com/sindresorhus/ky) HTTP client: ky owns the underlying transport (a cached `ky.create()` instance with a `beforeRequest` auth hook), while the facade keeps the CLI-specific concerns — URL diff --git a/docs/architecture/db-migrations.md b/docs/architecture/db-migrations.md index ab53f1cf6..8195a2ac5 100644 --- a/docs/architecture/db-migrations.md +++ b/docs/architecture/db-migrations.md @@ -113,6 +113,50 @@ When you run a command: | `026-isolate-catalogs-per-project` | Per-project catalog isolation for `skills`, `extensions`, `criteria`, `prompt-features` — backfills `slug = _id`, swaps global-unique `{id}`/slug indexes to `{projectId,slug}` / `{projectId,id}` (see [db.md](db.md#per-project-catalog-isolation-migration-026)) | | `027-uuid-keys-mcp-profileversions` | Opaque UUID `_id` + reference key for `mcp-servers` (`slug`) and `profile-versions` (`ref`) with `{projectId,slug}` / `{projectId,ref}` indexes; drops dead `prompt-feature-extractions` (see [db.md](db.md#per-project-entity-keying-migration-027)) | | `028-isolate-mcp-secrets-per-project` | Reconciles the token-manager `mcp-secrets` unique index — drops the legacy global-unique `{mcpId,name}` and (re)creates the per-project `{projectId,mcpId,name}` (see [token-manager.md](token-manager.md#mcp-secrets)) | +| `029-create-users-collection` | Creates the database-enforced unique identity index for authentication; uses Cosmos collection-creation extensions for continuous-backup accounts and preserves native MongoDB index creation (see below) | + +### Migration 029: users identity uniqueness + +`users.uniq_identity` must be a unique, non-sparse, unfiltered index on exactly +`(idp, idpTenant, idpSubject)` with simple collation. It guarantees one Scope user +per IdP identity. Unlike older catalog migrations, 029 **never falls back to a +non-unique identity index**. The index name is also used by the API's narrowly +scoped duplicate-key retry handling. + +| Backend | New collection | Existing collection | Email index | +| --- | --- | --- | --- | +| CosmosDB for MongoDB | `CreateCollection` includes the required `_id` index and `uniq_identity` at creation | Inspect and reuse only a compatible identity index; add the email index if missing | Non-sparse and non-unique | +| Native MongoDB | Native `createIndex` creates the collection and unique identity index | Idempotent native index creation; duplicate data and conflicting indexes remain errors | Sparse and non-unique, as before | + +Backend selection uses the Cosmos extension commands, not URI heuristics. Only +an explicit unknown-command error selects the native path. Cosmos authorization, +connectivity, and index failures do not silently switch backends. Actual index +metadata is checked before the migration reports success. + +Cosmos accounts using [continuous backup require unique indexes at collection +creation](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/indexing#unique-indexes). +Cosmos also [does not support sparse indexes](https://learn.microsoft.com/en-us/azure/cosmos-db/mongodb/feature-support-70#indexes-and-index-properties), +so 029 creates a normal, non-sparse advisory email index there instead. The email +index is non-unique on both backends. This does not change email storage or authentication policy. + +**Recovery is non-destructive.** If an existing Cosmos `users` collection lacks +the expected constraint, 029 stops, even if the collection is empty. It never +drops, recreates, renames, copies, or edits user data. Do not bypass the failure +by marking the migration as applied. Inspect and back up the collection, then +approve a separate recovery that preserves Scope UUIDs, roles, and references; +rerun 029 only after the collection has a compatible identity index. + +Throttling and recognized collection-creation races receive bounded retries +(three attempts), with collection/index discovery repeated before each attempt. +Transport failures propagate rather than blindly repeating DDL; a later rerun +can recognize a successfully created collection after a lost response. `down()` +remains log-only. + +Unit tests cover the Cosmos command contract; native MongoDB integration tests +exercise actual uniqueness, concurrency, reruns, and non-destructive failures. +**Live CosmosDB verification of the non-sparse email-index behavior is still +required.** See the [shared infrastructure guide](../shared-dev-infra.md) for +selecting a real account; use isolated test data for acceptance checks. ## CI/CD diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 331a41800..6d821be31 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -82,6 +82,30 @@ flowchart TB ## Data Flow +### User authentication + +Configured human callers send the **IdP access token unchanged** on each API call. +The API verifies its signature/claims before resolving an active Scope UUID and role +through Redis (hit: no Mongo) or an exact `(idp, tid, oid)` Mongo lookup (miss/outage). +Only `POST /api/v1/users/me` performs JIT/profile/`lastLoginAt`/bootstrap writes. +Every GET is read-only. Enrollment responses use no-store, and clients must not +prefetch or poll the POST. + +The Portal calls that POST first after an IdP callback; cached-account +reloads use plain `/users/me`. All application queries wait for the handshake, and +the API response—not MSAL claims—owns the Scope identity/role. Enrolled CLI bearers +remain compatible; new identities must explicitly enroll. Full RBAC/ownership, +interactive CLI login, and Scope internal-token plans are deferred; existing public +and anonymous rollout behavior is preserved. + +The access cache has a fixed/non-sliding TTL (`AUTH_USER_CACHE_TTL_SECONDS`, default +300 seconds), is namespaced by the Mongo database and identity tuple, and never +stores tokens. DB-only role/disable changes can remain stale until expiry; Redis +failure falls back to Mongo. See [Authentication & RBAC](auth-rbac.md) for the method +walkthrough, failures, cache isolation, and implemented/deferred boundary. + +### Benchmark execution + 1. **Submit** — A user submits a task via CLI or Portal, selecting a worker, model, criteria, and optionally an agent version. The API validates the selection (model must be in `supportedModels`, version must be active, at least one criterion required), resolves the agent version's queue, creates a run record in CosmosDB, and enqueues a message. 2. **Execute** — KEDA scales the target worker pod from 0→N. The worker dequeues the message, spins up the coding agent, and executes the task. The worker stamps `workerVersion` (exact build identity) on the run. 3. **Stream** — Workers publish real-time log events to Redis Pub/Sub. The API relays these as SSE streams to the CLI/Portal. diff --git a/docs/architecture/worker-requirements.md b/docs/architecture/worker-requirements.md index 035716b5d..3bb2efbd5 100644 --- a/docs/architecture/worker-requirements.md +++ b/docs/architecture/worker-requirements.md @@ -501,8 +501,22 @@ configuration and certificate failures exit immediately. Registration Jobs must not hide helper failures with `|| true`. The OSS Compose `register-agents` service uses this contract for Copilot and -Claude. Cross-repository overlays can mount additional manifests and invoke the -same helper before running their workers. +Claude. It waits for the API's Docker health check (`GET /health` must return +200), not merely for the API container to start. The probe uses Node's built-in +HTTP client, runs every 5 seconds with a 3-second timeout, and allows a 120-second +startup grace period followed by 12 consecutive failures before marking the API +unhealthy. A successful probe releases registration immediately, without waiting +out the grace period. + +This ordering also applies to `pnpm docker:dev:portal`: concurrent `tsx` startup +and `tsc --watch` compilation under the API CPU limit can outlast the registration +helper's readiness retry budget. The helper retains its own bounded retries for +transient failures after the API is healthy. Scheduler/workers still require +registration to exit successfully; failures are not ignored. If startup remains +blocked, inspect the API logs and Docker health status before increasing retries. + +Cross-repository overlays can mount additional manifests and invoke the same +helper before running their workers. --- diff --git a/package.json b/package.json index c59d07868..d7c72039e 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "docker:up:report": "scripts/worktree-env.sh && scripts/dev-compose.sh --profile report up --build", "docker:up:infra": "scripts/worktree-env.sh && scripts/dev-compose.sh up -d redis azurite mongodb", "docker:up:auth": "scripts/worktree-env.sh && scripts/dev-compose.sh --profile auth up -d entra-local", - "docker:up:portal": "scripts/worktree-env.sh && scripts/dev-compose.sh --profile portal up --build", + "docker:up:portal": "scripts/worktree-env.sh && scripts/dev-compose.sh --profile auth --profile portal up --build", "docker:dev": "scripts/worktree-env.sh && COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh -f docker-compose.yml -f docker-compose.dev.yml up --build --watch", "docker:dev:claude-code": "scripts/worktree-env.sh && SCOPE_REGISTER_CLAUDE_CODE_AGENT=true GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-claude-code/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile claude-code --profile report --profile post-processor --profile auth --profile portal up --build --watch", "docker:dev:copilot": "scripts/worktree-env.sh && SCOPE_REGISTER_COPILOT_AGENT=true GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD) COMPOSE_EXPERIMENTAL_WATCH_POLL=1 scripts/dev-compose.sh --env-file .env --env-file apps/workers/coder-acp-copilot/versions.env -f docker-compose.yml -f docker-compose.dev.yml --profile copilot --profile report --profile post-processor --profile auth --profile portal up --build --watch", diff --git a/packages/db-migrations/src/029-create-users-collection.test.ts b/packages/db-migrations/src/029-create-users-collection.test.ts new file mode 100644 index 000000000..614c4a480 --- /dev/null +++ b/packages/db-migrations/src/029-create-users-collection.test.ts @@ -0,0 +1,397 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { MongoNetworkError, MongoServerError, type Db } from "mongodb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sleep } from "./batch-update.js"; +import { CreateUsersCollection } from "./migrations/029-create-users-collection.js"; + +vi.mock("./batch-update.js", async (importOriginal) => ({ + ...await importOriginal(), + sleep: vi.fn(async () => {}), +})); + +interface TestIndex { + key: Record; + name: string; + unique?: boolean; + sparse?: boolean; + partialFilterExpression?: Record; + collation?: { locale: string }; +} + +const identityIndex: TestIndex = { + key: { idp: 1, idpTenant: 1, idpSubject: 1 }, + name: "uniq_identity", + unique: true, +}; +const idIndex: TestIndex = { key: { _id: 1 }, name: "_id_1", unique: true }; +const emailIndex: TestIndex = { key: { email: 1 }, name: "email", sparse: true }; +const cosmosEmailIndex: TestIndex = { key: { email: 1 }, name: "email" }; + +function makeDb(options: { + backend?: "cosmos" | "mongo"; + exists?: boolean; + indexes?: TestIndex[]; + documents?: { _id: string; role: string }[]; +} = {}) { + const state = { + exists: options.exists ?? false, + indexes: [...options.indexes ?? []], + documents: [...options.documents ?? []], + }; + const command = vi.fn(async (input: { + customAction: string; + collection: string; + indexes?: TestIndex[]; + }): Promise => { + if (options.backend === "mongo") { + throw new MongoServerError({ code: 59, errmsg: "no such command: 'customAction'" }); + } + if (input.customAction === "CreateCollection") { + if (state.exists) throw new MongoServerError({ code: 48, errmsg: "Collection already exists" }); + state.exists = true; + state.indexes = [...input.indexes ?? []]; + } + return { ok: 1 }; + }); + const createIndex = vi.fn(async ( + key: Record, + options: { name: string; unique?: boolean; sparse?: boolean }, + ) => { + if (!state.exists) { + state.exists = true; + state.indexes.push(idIndex); + } + if (!state.indexes.some((index) => index.name === options.name)) { + state.indexes.push({ key, ...options }); + } + return options.name; + }); + const readIndexes = vi.fn(async (): Promise => state.indexes); + const drop = vi.fn(async () => { + state.exists = false; + state.indexes = []; + state.documents = []; + }); + const dropIndex = vi.fn(); + const collection = vi.fn(() => ({ + createIndex, + listIndexes: vi.fn(() => ({ toArray: readIndexes })), + drop, + dropIndex, + })); + const listCollections = vi.fn(() => ({ + hasNext: vi.fn(async () => state.exists), + })); + const db = { command, collection, listCollections, dropCollection: drop } as unknown as Db; + return { db, command, createIndex, readIndexes, drop, collection, listCollections, state }; +} + +describe("migration 029: CreateUsersCollection", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("creates Cosmos identity indexes atomically and adds a non-sparse email index", async () => { + const fixture = makeDb(); + + await new CreateUsersCollection().up(fixture.db); + + expect(fixture.listCollections).toHaveBeenCalledWith({ name: "users" }, { nameOnly: true }); + expect(fixture.command).toHaveBeenCalledExactlyOnceWith({ + customAction: "CreateCollection", + collection: "users", + indexes: [idIndex, identityIndex], + }); + expect(fixture.createIndex).toHaveBeenCalledExactlyOnceWith({ email: 1 }, { name: "email" }); + expect(fixture.state.indexes).toEqual([idIndex, identityIndex, cosmosEmailIndex]); + expect(fixture.readIndexes).toHaveBeenCalled(); + }); + + it("falls back to native indexes only for an unsupported customAction command", async () => { + const fixture = makeDb({ backend: "mongo" }); + + await new CreateUsersCollection().up(fixture.db); + + expect(fixture.command).toHaveBeenCalledOnce(); + expect(fixture.createIndex).toHaveBeenNthCalledWith(1, identityIndex.key, { + name: "uniq_identity", unique: true, + }); + expect(fixture.createIndex).toHaveBeenNthCalledWith(2, emailIndex.key, { + name: "email", sparse: true, + }); + expect(fixture.state.indexes).toEqual([idIndex, identityIndex, emailIndex]); + }); + + it.each(["cosmos", "mongo"] as const)("safely reruns on populated %s collections", async (backend) => { + const documents = [{ _id: "scope-user-id", role: "admin" }]; + const fixture = makeDb({ backend, exists: true, indexes: [idIndex, identityIndex], documents }); + const migration = new CreateUsersCollection(); + + await migration.up(fixture.db); + await migration.up(fixture.db); + + expect(fixture.command).toHaveBeenCalledWith({ + customAction: "GetCollection", collection: "users", + }); + expect(fixture.command.mock.calls.every(([input]) => input.customAction === "GetCollection")).toBe(true); + expect(fixture.state.documents).toEqual(documents); + expect(fixture.drop).not.toHaveBeenCalled(); + if (backend === "cosmos") { + expect(fixture.createIndex).toHaveBeenCalledExactlyOnceWith({ email: 1 }, { name: "email" }); + expect(fixture.state.indexes).toContainEqual(cosmosEmailIndex); + } else { + expect(fixture.state.indexes).toContainEqual(emailIndex); + } + }); + + it.each([ + cosmosEmailIndex, + { ...cosmosEmailIndex, unique: false, sparse: false }, + ])("reuses a compatible existing Cosmos email index: %j", async (index) => { + const fixture = makeDb({ exists: true, indexes: [idIndex, identityIndex, index] }); + + await new CreateUsersCollection().up(fixture.db); + + expect(fixture.state.indexes).toContainEqual(index); + expect(fixture.createIndex).not.toHaveBeenCalled(); + expect(fixture.collection().dropIndex).not.toHaveBeenCalled(); + }); + + it.each([ + { documents: [] }, + { documents: [{ _id: "existing-user", role: "admin" }] }, + ])( + "refuses an existing Cosmos collection without the identity index (documents: $documents)", + async ({ documents }) => { + const fixture = makeDb({ exists: true, indexes: [idIndex], documents }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/continuous backup/i); + + expect(fixture.state.documents).toEqual(documents); + expect(fixture.createIndex).not.toHaveBeenCalled(); + expect(fixture.drop).not.toHaveBeenCalled(); + expect(fixture.command).toHaveBeenCalledExactlyOnceWith({ + customAction: "GetCollection", collection: "users", + }); + }, + ); + + it.each([ + { ...identityIndex, key: { idpSubject: 1 } }, + { ...identityIndex, name: "different_name" }, + { ...identityIndex, unique: false }, + { ...identityIndex, sparse: true }, + { ...identityIndex, partialFilterExpression: { idp: "entra" } }, + { ...identityIndex, collation: { locale: "en" } }, + ])("rejects incompatible Cosmos identity index metadata: %j", async (index) => { + const fixture = makeDb({ exists: true, indexes: [idIndex, index] }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/uniq_identity/); + + expect(fixture.createIndex).not.toHaveBeenCalled(); + expect(fixture.drop).not.toHaveBeenCalled(); + }); + + it("does not trust successful Cosmos creation without the required index", async () => { + const fixture = makeDb(); + fixture.command.mockImplementationOnce(async () => { + fixture.state.exists = true; + fixture.state.indexes = [idIndex]; + return { ok: 1 }; + }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/uniq_identity/); + expect(fixture.createIndex).not.toHaveBeenCalled(); + }); + + it("does not trust successful native index creation without the required index", async () => { + const fixture = makeDb({ backend: "mongo", exists: true, indexes: [idIndex] }); + fixture.createIndex.mockResolvedValueOnce("uniq_identity"); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/uniq_identity/); + expect(fixture.createIndex).toHaveBeenCalledOnce(); + }); + + it.each([undefined, { ok: 0 }, { ok: "1" }])("rejects malformed Cosmos responses: %j", async (response) => { + const fixture = makeDb(); + fixture.command.mockResolvedValueOnce(response); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/response/i); + expect(fixture.createIndex).not.toHaveBeenCalled(); + }); + + it("rejects malformed index metadata", async () => { + const fixture = makeDb(); + fixture.readIndexes.mockResolvedValueOnce({ indexes: [identityIndex] }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/index metadata/i); + }); + + it("rechecks a recognized concurrent collection-creation race", async () => { + const fixture = makeDb(); + fixture.command.mockImplementationOnce(async () => { + fixture.state.exists = true; + fixture.state.indexes = [idIndex, identityIndex]; + throw new MongoServerError({ code: 48, errmsg: "Collection already exists" }); + }); + + await new CreateUsersCollection().up(fixture.db); + + expect(fixture.command).toHaveBeenNthCalledWith(2, { + customAction: "GetCollection", collection: "users", + }); + expect(fixture.readIndexes).toHaveBeenCalled(); + expect(fixture.createIndex).toHaveBeenCalledExactlyOnceWith({ email: 1 }, { name: "email" }); + }); + + it("does not accept a concurrent creator's incompatible collection", async () => { + const fixture = makeDb(); + fixture.command.mockImplementationOnce(async () => { + fixture.state.exists = true; + fixture.state.indexes = [idIndex]; + throw new MongoServerError({ code: 48, errmsg: "Collection already exists" }); + }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/uniq_identity/); + expect(fixture.createIndex).not.toHaveBeenCalled(); + }); + + it("recovers on rerun when creation completed before a transport failure", async () => { + const fixture = makeDb(); + const error = new MongoNetworkError("response lost"); + fixture.command.mockImplementationOnce(async () => { + fixture.state.exists = true; + fixture.state.indexes = [idIndex, identityIndex]; + throw error; + }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toBe(error); + expect(sleep).not.toHaveBeenCalled(); + await new CreateUsersCollection().up(fixture.db); + + expect(fixture.command).toHaveBeenNthCalledWith(2, { + customAction: "GetCollection", collection: "users", + }); + expect(fixture.createIndex).toHaveBeenCalledExactlyOnceWith({ email: 1 }, { name: "email" }); + }); + + it("respects throttling delay and rechecks state before retrying creation", async () => { + const fixture = makeDb(); + fixture.command.mockRejectedValueOnce(new MongoServerError({ + code: 16500, errmsg: "Too many requests; RetryAfterMs=3000", + })); + + await new CreateUsersCollection().up(fixture.db); + + expect(sleep).toHaveBeenCalledExactlyOnceWith(3000); + expect(fixture.listCollections).toHaveBeenCalledTimes(2); + }); + + it.each([48, 16500])("bounds retries for error %i", async (code) => { + const fixture = makeDb(); + const error = new MongoServerError({ code, errmsg: "Retryable failure" }); + fixture.command.mockRejectedValue(error); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toBe(error); + expect(fixture.command).toHaveBeenCalledTimes(3); + expect(sleep).toHaveBeenCalledTimes(2); + }); + + it.each([ + new MongoServerError({ code: 13, errmsg: "not authorized" }), + new MongoServerError({ code: 67, errmsg: "Cannot create unique index" }), + new MongoServerError({ code: 115, errmsg: "Command not supported" }), + new MongoServerError({ code: 85, errmsg: "Index options conflict" }), + new MongoNetworkError("connection interrupted"), + ])("does not interpret Cosmos failures as native support: $message", async (error) => { + const fixture = makeDb(); + fixture.command.mockRejectedValueOnce(error); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toBe(error); + expect(fixture.createIndex).not.toHaveBeenCalled(); + expect(sleep).not.toHaveBeenCalled(); + }); + + it.each([ + new MongoServerError({ code: 11000, errmsg: "duplicate key" }), + new MongoServerError({ code: 85, errmsg: "Index options conflict" }), + new MongoNetworkError("connection interrupted"), + ])("propagates native identity index creation failure: $message", async (error) => { + const fixture = makeDb({ backend: "mongo" }); + fixture.createIndex.mockRejectedValueOnce(error); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toBe(error); + expect(fixture.createIndex).toHaveBeenCalledTimes(1); + }); + + it("propagates native email index creation failures and resumes safely on rerun", async () => { + const fixture = makeDb({ backend: "mongo", exists: true, indexes: [idIndex, identityIndex] }); + const error = new MongoNetworkError("connection interrupted"); + fixture.createIndex.mockResolvedValueOnce("uniq_identity").mockRejectedValueOnce(error); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toBe(error); + await new CreateUsersCollection().up(fixture.db); + expect(fixture.state.indexes).toContainEqual(emailIndex); + }); + + it.each([ + new MongoNetworkError("connection interrupted"), + new MongoServerError({ code: 13, errmsg: "not authorized" }), + new MongoServerError({ code: 85, errmsg: "Index options conflict" }), + ])("propagates Cosmos email failures and resumes safely on rerun: $message", async (error) => { + const fixture = makeDb({ exists: true, indexes: [idIndex, identityIndex] }); + fixture.createIndex.mockRejectedValueOnce(error); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toBe(error); + expect(fixture.createIndex).toHaveBeenCalledExactlyOnceWith({ email: 1 }, { name: "email" }); + await new CreateUsersCollection().up(fixture.db); + expect(fixture.state.indexes).toContainEqual(cosmosEmailIndex); + expect(fixture.drop).not.toHaveBeenCalled(); + }); + + it.each([ + { ...cosmosEmailIndex, sparse: true }, + { ...cosmosEmailIndex, unique: true }, + { ...cosmosEmailIndex, key: { other: 1 } }, + { ...cosmosEmailIndex, partialFilterExpression: { email: { $exists: true } } }, + ])("rejects incompatible Cosmos email indexes without replacing them: %j", async (index) => { + const fixture = makeDb({ exists: true, indexes: [idIndex, identityIndex, index] }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/email/); + + expect(fixture.state.indexes).toEqual([idIndex, identityIndex, index]); + expect(fixture.drop).not.toHaveBeenCalled(); + expect(fixture.collection().dropIndex).not.toHaveBeenCalled(); + }); + + it("does not trust Cosmos email creation success without an actual index", async () => { + const fixture = makeDb({ exists: true, indexes: [idIndex, identityIndex] }); + fixture.createIndex.mockResolvedValueOnce("email"); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/email/); + }); + + it("verifies native email index options instead of trusting createIndex success", async () => { + const fixture = makeDb({ backend: "mongo", exists: true, indexes: [ + idIndex, identityIndex, { ...emailIndex, sparse: false }, + ] }); + + await expect(new CreateUsersCollection().up(fixture.db)).rejects.toThrow(/email/); + }); + + it("keeps down non-destructive", async () => { + const fixture = makeDb({ exists: true, indexes: [idIndex, identityIndex] }); + + await new CreateUsersCollection().down(fixture.db); + + expect(fixture.command).not.toHaveBeenCalled(); + expect(fixture.collection).not.toHaveBeenCalled(); + expect(fixture.drop).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/db-migrations/src/check-migrations.test.ts b/packages/db-migrations/src/check-migrations.test.ts index f64da9081..de7513115 100644 --- a/packages/db-migrations/src/check-migrations.test.ts +++ b/packages/db-migrations/src/check-migrations.test.ts @@ -59,11 +59,12 @@ describe("checkMigrations", () => { "026-isolate-catalogs-per-project.ts", "027-uuid-keys-mcp-profileversions.ts", "028-isolate-mcp-secrets-per-project.ts", + "029-create-users-collection.ts", ]); const result = await checkMigrations(db); expect(result.ready).toBe(true); expect(result.pending).toEqual([]); - expect(result.totalApplied).toBe(28); + expect(result.totalApplied).toBe(29); expect(result.applied).toEqual([ "001-backfill-task-prompts.ts", "002-create-indexes.ts", @@ -93,6 +94,7 @@ describe("checkMigrations", () => { "026-isolate-catalogs-per-project.ts", "027-uuid-keys-mcp-profileversions.ts", "028-isolate-mcp-secrets-per-project.ts", + "029-create-users-collection.ts", ]); }); @@ -100,7 +102,7 @@ describe("checkMigrations", () => { const db = makeMockDb(["001-backfill-task-prompts.ts"]); const result = await checkMigrations(db); expect(result.ready).toBe(false); - expect(result.pending).toEqual(["002-create-indexes.ts", "003-create-skill-indexes.ts", "004-add-submission-id-index.ts", "005-backfill-iteration-durations.ts", "006-split-status-outcome.ts", "007-rename-exhausted-to-finished.ts", "008-backfill-ai-call-count.ts", "009-add-requests-filter-indexes.ts", "010-add-requests-pagination-index.ts", "011-add-profile-indexes.ts", "012-add-profile-name-index.ts", "013-remove-logs-from-docs.ts", "014-introduce-runs-and-run.ts", "015-add-priority-and-scheduler-index.ts", "016-fix-scheduler-sort-index.ts", "017-add-post-processor-dispatch-index.ts", "018-backfill-criteria-gates.ts", "019-backfill-task-prompt-type.ts", "020-create-codebase-indexes.ts", "021-add-runs-filter-indexes.ts", "022-add-runs-sort-indexes.ts", "023-add-runs-search-task-index.ts", "024-add-criteria-sort-index.ts", "025-create-projects.ts", "026-isolate-catalogs-per-project.ts", "027-uuid-keys-mcp-profileversions.ts", "028-isolate-mcp-secrets-per-project.ts"]); + expect(result.pending).toEqual(["002-create-indexes.ts", "003-create-skill-indexes.ts", "004-add-submission-id-index.ts", "005-backfill-iteration-durations.ts", "006-split-status-outcome.ts", "007-rename-exhausted-to-finished.ts", "008-backfill-ai-call-count.ts", "009-add-requests-filter-indexes.ts", "010-add-requests-pagination-index.ts", "011-add-profile-indexes.ts", "012-add-profile-name-index.ts", "013-remove-logs-from-docs.ts", "014-introduce-runs-and-run.ts", "015-add-priority-and-scheduler-index.ts", "016-fix-scheduler-sort-index.ts", "017-add-post-processor-dispatch-index.ts", "018-backfill-criteria-gates.ts", "019-backfill-task-prompt-type.ts", "020-create-codebase-indexes.ts", "021-add-runs-filter-indexes.ts", "022-add-runs-sort-indexes.ts", "023-add-runs-search-task-index.ts", "024-add-criteria-sort-index.ts", "025-create-projects.ts", "026-isolate-catalogs-per-project.ts", "027-uuid-keys-mcp-profileversions.ts", "028-isolate-mcp-secrets-per-project.ts", "029-create-users-collection.ts"]); expect(result.applied).toEqual(["001-backfill-task-prompts.ts"]); expect(result.totalApplied).toBe(1); }); @@ -138,6 +140,7 @@ describe("checkMigrations", () => { "026-isolate-catalogs-per-project.ts", "027-uuid-keys-mcp-profileversions.ts", "028-isolate-mcp-secrets-per-project.ts", + "029-create-users-collection.ts", ]); expect(result.applied).toEqual([]); expect(result.totalApplied).toBe(0); @@ -173,12 +176,13 @@ describe("checkMigrations", () => { "026-isolate-catalogs-per-project.ts", "027-uuid-keys-mcp-profileversions.ts", "028-isolate-mcp-secrets-per-project.ts", + "029-create-users-collection.ts", "999-future-migration.ts", ]); const result = await checkMigrations(db); expect(result.ready).toBe(true); expect(result.pending).toEqual([]); - expect(result.totalApplied).toBe(29); + expect(result.totalApplied).toBe(30); }); it("caches results within TTL", async () => { diff --git a/packages/db-migrations/src/migrations/029-create-users-collection.ts b/packages/db-migrations/src/migrations/029-create-users-collection.ts new file mode 100644 index 000000000..ffa1ea209 --- /dev/null +++ b/packages/db-migrations/src/migrations/029-create-users-collection.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Migration: Create the `users` collection and its indexes. + * + * Backs API authentication (identity-only phase). Each user is keyed by the + * IdP identity triple `(idp, idpTenant, idpSubject)`; the Scope User ID (`_id`) + * is an app-owned UUID. + * + * - Unique compound `(idp, idpTenant, idpSubject)` — the durable identity key + * used by the JIT upsert lookup; guarantees one record per IdP principal. + * - Non-unique index on optional `email`, sparse only on native MongoDB. + * + * Cosmos continuous backup requires unique indexes at collection creation. + * Existing incompatible collections are never dropped or downgraded to a + * non-unique identity index. Cosmos does not support sparse email indexes. + */ + +import { MongoServerError, type Collection, type Db } from "mongodb"; +import type { MigrationInterface } from "mongo-migrate-ts"; +import { getRetryAfterMs, sleep } from "../batch-update.js"; + +const USERS_COLLECTION = "users"; +const IDENTITY_KEY = { idp: 1, idpTenant: 1, idpSubject: 1 } as const; +const MAX_ATTEMPTS = 3; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function matchesKey(value: unknown, expected: Record): boolean { + return isRecord(value) && + Object.keys(value).length === Object.keys(expected).length && + Object.entries(expected).every(([field, direction]) => value[field] === direction); +} + +async function readIndexes(users: Collection): Promise[]> { + const indexes: unknown = await users.listIndexes().toArray(); + if (!Array.isArray(indexes) || !indexes.every(isRecord)) { + throw new Error("[029] Invalid users index metadata"); + } + return indexes; +} + +async function useCosmosCollection(db: Db, exists: boolean): Promise { + const command = exists + ? { customAction: "GetCollection", collection: USERS_COLLECTION } + : { + customAction: "CreateCollection", + collection: USERS_COLLECTION, + indexes: [ + { key: { _id: 1 }, name: "_id_1", unique: true }, + { key: IDENTITY_KEY, name: "uniq_identity", unique: true }, + ], + }; + let response: unknown; + try { + response = await db.command(command); + } catch (error) { + if (error instanceof MongoServerError && error.code === 59) return false; + throw error; + } + if (!isRecord(response) || response.ok !== 1) { + throw new Error(`[029] Invalid ${command.customAction} response`); + } + return true; +} + +async function ensureUsersIndexes(db: Db): Promise { + const exists = await db.listCollections({ name: USERS_COLLECTION }, { nameOnly: true }).hasNext(); + const cosmos = await useCosmosCollection(db, exists); + const users = db.collection(USERS_COLLECTION); + + if (!cosmos) { + await users.createIndex( + IDENTITY_KEY, + { unique: true, name: "uniq_identity" }, + ); + } + + const indexes = await readIndexes(users); + const validIdentity = indexes.some((index) => + index.name === "uniq_identity" && + matchesKey(index.key, IDENTITY_KEY) && + index.unique === true && + (index.sparse === undefined || index.sparse === false) && + index.partialFilterExpression === undefined && + (index.collation === undefined || + (isRecord(index.collation) && index.collation.locale === "simple")), + ); + if (!validIdentity) { + throw new Error( + "[029] users.uniq_identity must be a unique, non-sparse, unfiltered index " + + "on (idp, idpTenant, idpSubject) with simple collation." + + (cosmos + ? " CosmosDB continuous backup requires unique indexes at collection creation. " + + "Operator-managed recovery is required; this migration will not drop, " + + "recreate, or modify data in an incompatible users collection." + : ""), + ); + } + console.log(" [029] Verified unique identity index on users"); + + const validEmail = (index: Record): boolean => + index.name === "email" && + matchesKey(index.key, { email: 1 }) && + (cosmos + ? index.sparse === undefined || index.sparse === false + : index.sparse === true) && + (index.unique === undefined || index.unique === false) && + index.partialFilterExpression === undefined; + if (!indexes.some(validEmail)) { + await users.createIndex( + { email: 1 }, + cosmos ? { name: "email" } : { sparse: true, name: "email" }, + ); + const updatedIndexes = await readIndexes(users); + if (!updatedIndexes.some(validEmail)) { + throw new Error(`[029] users.email must be a ${cosmos ? "non-sparse" : "sparse"}, non-unique index on email`); + } + } + console.log(` [029] Verified ${cosmos ? "non-sparse email index on CosmosDB" : "sparse email index on native MongoDB"}`); +} + +export class CreateUsersCollection implements MigrationInterface { + async up(db: Db): Promise { + for (let attempt = 0; ; attempt++) { + try { + await ensureUsersIndexes(db); + return; + } catch (error) { + // Re-enter through collection/index discovery after throttling or a + // competing creator. Never blindly retry DDL after a transport failure. + if (!(error instanceof MongoServerError) || + (error.code !== 16500 && error.code !== 48) || + attempt >= MAX_ATTEMPTS - 1) { + throw error; + } + const delay = error.code === 16500 + ? Math.max(getRetryAfterMs(error), 500 * 2 ** attempt) + : 500 * 2 ** attempt; + console.warn(` [029] Retry ${attempt + 1}/${MAX_ATTEMPTS - 1} after code ${error.code}; waiting ${delay}ms`); + await sleep(delay); + } + } + } + + async down(db: Db): Promise { + console.log( + " Skipping index/collection drop — drop manually if needed", + ); + } +} \ No newline at end of file diff --git a/packages/db-migrations/src/required-migrations.ts b/packages/db-migrations/src/required-migrations.ts index ccbcf01f3..d0683cd40 100644 --- a/packages/db-migrations/src/required-migrations.ts +++ b/packages/db-migrations/src/required-migrations.ts @@ -41,4 +41,5 @@ export const REQUIRED_MIGRATIONS: readonly string[] = [ "026-isolate-catalogs-per-project.ts", "027-uuid-keys-mcp-profileversions.ts", "028-isolate-mcp-secrets-per-project.ts", + "029-create-users-collection.ts", ]; diff --git a/packages/shared/package.json b/packages/shared/package.json index 366c8688d..934974c7d 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -39,6 +39,7 @@ "cockatiel": "^3.2.1", "gray-matter": "^4.0.3", "ioredis": "^5.4.1", + "jose": "^5.9.6", "mongodb": "^6.12.0", "tar": "^7.5.8", "uuid": "^11.1.1", diff --git a/packages/shared/src/auth/claims-enricher.test.ts b/packages/shared/src/auth/claims-enricher.test.ts new file mode 100644 index 000000000..780a94954 --- /dev/null +++ b/packages/shared/src/auth/claims-enricher.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { ClaimsProfileEnricher } from "./claims-enricher.js"; +import type { VerifiedIdentity } from "./types.js"; + +const identity: VerifiedIdentity = { + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", + email: "ada@example.com", + displayName: "Ada Lovelace", + emailVerified: true, +}; + +describe("ClaimsProfileEnricher", () => { + it("returns the profile from token claims", async () => { + const enricher = new ClaimsProfileEnricher(); + const profile = await enricher.enrich(identity, "raw-token"); + expect(profile).toEqual({ + email: "ada@example.com", + displayName: "Ada Lovelace", + emailVerified: true, + }); + }); + + it("passes through missing profile fields as undefined", async () => { + const enricher = new ClaimsProfileEnricher(); + const profile = await enricher.enrich( + { idp: "entra", idpTenant: "t", idpSubject: "s" }, + "raw-token", + ); + expect(profile).toEqual({ + email: undefined, + displayName: undefined, + emailVerified: undefined, + }); + }); + + it("identifies itself as the claims enricher", () => { + expect(new ClaimsProfileEnricher().id).toBe("claims"); + }); +}); diff --git a/packages/shared/src/auth/claims-enricher.ts b/packages/shared/src/auth/claims-enricher.ts new file mode 100644 index 000000000..0395469cb --- /dev/null +++ b/packages/shared/src/auth/claims-enricher.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ProfileEnricher, UserProfile, VerifiedIdentity } from "./types.js"; + +/** + * Default {@link ProfileEnricher}: returns the profile straight from the + * verified token claims. No network call and no client secret required. + * + * A future `GraphProfileEnricher` would implement the same interface using the + * On-Behalf-Of flow to call Microsoft Graph `/me`, and would be selected via + * configuration without changing any call site. + */ +export class ClaimsProfileEnricher implements ProfileEnricher { + readonly id = "claims"; + + async enrich( + identity: VerifiedIdentity, + _rawToken: string, + ): Promise { + return { + email: identity.email, + displayName: identity.displayName, + emailVerified: identity.emailVerified, + }; + } +} diff --git a/packages/shared/src/auth/config.test.ts b/packages/shared/src/auth/config.test.ts new file mode 100644 index 000000000..bd169ed7d --- /dev/null +++ b/packages/shared/src/auth/config.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { bootstrapAdminKey, loadAuthConfigFromEnv } from "./config.js"; + +const TENANT = "11111111-1111-1111-1111-111111111111"; +const SUBJECT = "aaaaaaaa-0000-0000-0000-000000000001"; +const API_CLIENT_ID = "cccccccc-0000-0000-0000-000000000005"; + +function baseEnv(): NodeJS.ProcessEnv { + return { + AUTH_PROVIDER: "entra", + AUTH_AUTHORITY: `https://localhost:8443/${TENANT}`, + AUTH_API_CLIENT_ID: API_CLIENT_ID, + }; +} + +describe("loadAuthConfigFromEnv", () => { + it("returns null when AUTH_* is not configured", () => { + expect(loadAuthConfigFromEnv({})).toBeNull(); + }); + + it("throws when any required variable is missing", () => { + const { AUTH_API_CLIENT_ID: _omitted, ...partial } = baseEnv(); + void _omitted; + expect(() => loadAuthConfigFromEnv(partial)).toThrow( + /Incomplete authentication configuration: missing AUTH_API_CLIENT_ID/, + ); + }); + + it("throws when optional auth settings are present without required settings", () => { + expect(() => + loadAuthConfigFromEnv({ AUTH_SCOPES: "api://scope/access" }), + ).toThrow(/AUTH_PROVIDER, AUTH_AUTHORITY, AUTH_API_CLIENT_ID/); + }); + + it("throws on an unsupported provider", () => { + expect(() => + loadAuthConfigFromEnv({ ...baseEnv(), AUTH_PROVIDER: "okta" }), + ).toThrow(/Unsupported AUTH_PROVIDER/); + }); + + it("builds an entra runtime from the required variables", () => { + const runtime = loadAuthConfigFromEnv(baseEnv()); + expect(runtime).not.toBeNull(); + expect(runtime?.provider.id).toBe("entra"); + expect(runtime?.enricher).toBeDefined(); + expect(runtime?.bootstrapAdmins.size).toBe(0); + expect(runtime?.bootstrapTenants.size).toBe(0); + expect(runtime?.userCacheTtlSeconds).toBe(300); + }); + + it.each(["1", "60", "300", "9007199254740991"])("accepts a positive safe cache TTL of %s", (ttl) => { + expect(loadAuthConfigFromEnv({ + ...baseEnv(), + AUTH_USER_CACHE_TTL_SECONDS: ttl, + })?.userCacheTtlSeconds).toBe(Number(ttl)); + }); + + it.each([ + "", " ", " 300", "300 ", "0", "-1", "0.5", "1.0", "1e3", "+5", + "0x10", "NaN", "Infinity", "abc", "300seconds", "9007199254740992", + ])("rejects an invalid cache TTL of %j", (ttl) => { + expect(() => loadAuthConfigFromEnv({ + ...baseEnv(), + AUTH_USER_CACHE_TTL_SECONDS: ttl, + })).toThrow(/AUTH_USER_CACHE_TTL_SECONDS must be a positive safe integer/); + }); + + it("does not enable IdP configuration when only a valid cache TTL is set", () => { + expect(loadAuthConfigFromEnv({ AUTH_USER_CACHE_TTL_SECONDS: "60" })).toBeNull(); + }); + + it("validates a supplied cache TTL even with authentication disabled", () => { + expect(() => loadAuthConfigFromEnv({ AUTH_USER_CACHE_TTL_SECONDS: "0" })) + .toThrow(/AUTH_USER_CACHE_TTL_SECONDS/); + }); + + it("does not let a cache TTL bypass incomplete IdP configuration", () => { + expect(() => loadAuthConfigFromEnv({ + AUTH_USER_CACHE_TTL_SECONDS: "60", + AUTH_PROVIDER: "entra", + })).toThrow(/AUTH_AUTHORITY, AUTH_API_CLIENT_ID/); + }); + + it("accepts the entra-local issuer/JWKS overrides without error", () => { + const runtime = loadAuthConfigFromEnv({ + ...baseEnv(), + AUTH_ISSUER_TEMPLATE: "https://localhost:8443/{tenantid}/v2.0", + AUTH_JWKS_URI: `https://localhost:8443/${TENANT}/discovery/v2.0/keys`, + }); + expect(runtime?.provider.id).toBe("entra"); + }); + + it("parses bootstrap admins and tenants as trimmed, non-empty sets", () => { + const runtime = loadAuthConfigFromEnv({ + ...baseEnv(), + AUTH_BOOTSTRAP_ADMINS: ` entra:${TENANT}/${SUBJECT} , , entra:${TENANT}/other `, + AUTH_BOOTSTRAP_TENANTS: `${TENANT}, `, + }); + expect(runtime?.bootstrapAdmins).toEqual( + new Set([ + bootstrapAdminKey("entra", TENANT, SUBJECT), + `entra:${TENANT}/other`, + ]), + ); + expect(runtime?.bootstrapTenants).toEqual(new Set([TENANT])); + }); + + it("requires a tenant allowlist when bootstrap admins are configured", () => { + expect(() => + loadAuthConfigFromEnv({ + ...baseEnv(), + AUTH_BOOTSTRAP_ADMINS: `entra:${TENANT}/${SUBJECT}`, + }), + ).toThrow(/AUTH_BOOTSTRAP_TENANTS is required/); + }); +}); + +describe("bootstrapAdminKey", () => { + it("formats the identity triple as `${idp}:${tenant}/${subject}`", () => { + expect(bootstrapAdminKey("entra", TENANT, SUBJECT)).toBe( + `entra:${TENANT}/${SUBJECT}`, + ); + }); +}); diff --git a/packages/shared/src/auth/config.ts b/packages/shared/src/auth/config.ts new file mode 100644 index 000000000..fe8edaf93 --- /dev/null +++ b/packages/shared/src/auth/config.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ClaimsProfileEnricher } from "./claims-enricher.js"; +import { EntraIdAuthProvider } from "./entra.js"; +import type { AuthProvider, ProfileEnricher } from "./types.js"; + +const AUTH_ENV_KEYS = [ + "AUTH_PROVIDER", + "AUTH_AUTHORITY", + "AUTH_API_CLIENT_ID", + "AUTH_SCOPES", + "AUTH_ISSUER_TEMPLATE", + "AUTH_JWKS_URI", + "AUTH_BOOTSTRAP_ADMINS", + "AUTH_BOOTSTRAP_TENANTS", +] as const; + +const REQUIRED_AUTH_ENV_KEYS = [ + "AUTH_PROVIDER", + "AUTH_AUTHORITY", + "AUTH_API_CLIENT_ID", +] as const; + +/** Everything the API needs to authenticate requests, assembled from env. */ +export interface AuthRuntime { + /** Token verifier for the configured IdP. */ + provider: AuthProvider; + /** Profile resolver (claims today; Graph/OBO later). */ + enricher: ProfileEnricher; + /** + * Bootstrap admin identity keys, each formatted `${idp}:${tenant}/${subject}` + * (e.g. `entra:/`). Promote-only — never used to demote. + */ + bootstrapAdmins: Set; + /** Required tenant allowlist within which bootstrap promotion may apply. */ + bootstrapTenants: Set; + /** Fixed lifetime of an active Scope-user cache entry. */ + userCacheTtlSeconds: number; +} + +/** Build the identity key used to match {@link AuthRuntime.bootstrapAdmins}. */ +export function bootstrapAdminKey( + idp: string, + idpTenant: string, + idpSubject: string, +): string { + return `${idp}:${idpTenant}/${idpSubject}`; +} + +function parseCsvSet(raw: string | undefined): Set { + return new Set( + (raw ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ); +} + +function getRequiredAuthValue( + env: NodeJS.ProcessEnv, + key: (typeof REQUIRED_AUTH_ENV_KEYS)[number], +): string { + const value = env[key]?.trim(); + if (!value) { + throw new Error(`Incomplete authentication configuration: missing ${key}`); + } + return value; +} + +function parseUserCacheTtl(raw: string | undefined): number { + if (raw === undefined) return 300; + const value = Number(raw); + if (!/^[0-9]+$/.test(raw) || !Number.isSafeInteger(value) || value <= 0) { + throw new Error("AUTH_USER_CACHE_TTL_SECONDS must be a positive safe integer"); + } + return value; +} + +/** + * Build the {@link AuthRuntime} from environment variables. Returns `null` only + * when every IdP auth setting is absent, so the API can still run in the + * non-breaking anonymous mode. Partial configuration throws to prevent auth + * from being disabled by a missing or misspelled required setting. + * + * Required to enable auth: `AUTH_PROVIDER`, `AUTH_AUTHORITY`, + * `AUTH_API_CLIENT_ID`. Optional: `AUTH_ISSUER_TEMPLATE`, `AUTH_JWKS_URI`, + * `AUTH_BOOTSTRAP_ADMINS`, `AUTH_BOOTSTRAP_TENANTS`. Bootstrap tenants are + * required whenever bootstrap admins are configured. `AUTH_USER_CACHE_TTL_SECONDS` + * defaults to 300 when unset and is validated even without IdP configuration; + * setting it alone does not enable authentication. + * + * `AUTH_ISSUER_TEMPLATE` / `AUTH_JWKS_URI` override the Entra-cloud defaults so + * a self-hosted issuer (e.g. the `entra-local` emulator, whose issuer is + * `https://localhost:8443/{tenantid}/v2.0`) can be validated without code + * changes. `{tenantid}` is substituted per-token in the issuer template. + */ +export function loadAuthConfigFromEnv( + env: NodeJS.ProcessEnv = process.env, +): AuthRuntime | null { + const userCacheTtlSeconds = parseUserCacheTtl(env.AUTH_USER_CACHE_TTL_SECONDS); + const configuredKeys = AUTH_ENV_KEYS.filter( + (key) => (env[key]?.trim().length ?? 0) > 0, + ); + if (configuredKeys.length === 0) { + return null; + } + + const missingKeys = REQUIRED_AUTH_ENV_KEYS.filter( + (key) => !env[key]?.trim(), + ); + if (missingKeys.length > 0) { + throw new Error( + `Incomplete authentication configuration: missing ${missingKeys.join(", ")}`, + ); + } + + const providerId = getRequiredAuthValue(env, "AUTH_PROVIDER"); + const authority = getRequiredAuthValue(env, "AUTH_AUTHORITY"); + const audience = getRequiredAuthValue(env, "AUTH_API_CLIENT_ID"); + + if (providerId !== "entra") { + throw new Error( + `Unsupported AUTH_PROVIDER "${providerId}" (only "entra" is supported)`, + ); + } + + const issuerTemplate = env.AUTH_ISSUER_TEMPLATE?.trim(); + const jwksUri = env.AUTH_JWKS_URI?.trim(); + + const provider = new EntraIdAuthProvider({ + authority, + audience, + ...(issuerTemplate ? { issuerTemplate } : {}), + ...(jwksUri ? { jwksUri } : {}), + }); + const enricher = new ClaimsProfileEnricher(); + const bootstrapAdmins = parseCsvSet(env.AUTH_BOOTSTRAP_ADMINS); + const bootstrapTenants = parseCsvSet(env.AUTH_BOOTSTRAP_TENANTS); + + if (bootstrapAdmins.size > 0 && bootstrapTenants.size === 0) { + throw new Error( + "AUTH_BOOTSTRAP_TENANTS is required when AUTH_BOOTSTRAP_ADMINS is configured", + ); + } + + return { + provider, + enricher, + bootstrapAdmins, + bootstrapTenants, + userCacheTtlSeconds, + }; +} diff --git a/packages/shared/src/auth/entra.test.ts b/packages/shared/src/auth/entra.test.ts new file mode 100644 index 000000000..f92510cfb --- /dev/null +++ b/packages/shared/src/auth/entra.test.ts @@ -0,0 +1,360 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createServer } from "node:http"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { createLocalJWKSet, exportJWK, SignJWT, generateKeyPair } from "jose"; +import type { + JSONWebKeySet, + JWTVerifyGetKey, + KeyLike, +} from "jose"; +import { EntraIdAuthProvider, type EntraJwks } from "./entra.js"; +import { AuthError } from "./types.js"; + +const TENANT = "11111111-1111-1111-1111-111111111111"; +const OTHER_TENANT = "99999999-9999-9999-9999-999999999999"; +const SUBJECT = "22222222-2222-2222-2222-222222222222"; +const AUDIENCE = "api-client-id"; +const AUTHORITY = "https://login.microsoftonline.com/common"; +const KEY_ID = "test-signing-key"; +const KEY_ISSUER = "https://login.microsoftonline.com/{tenantid}/v2.0"; + +let privateKey: KeyLike; +let publicKey: KeyLike; +let wrongPrivateKey: KeyLike; +let publicJwk: JSONWebKeySet["keys"][number]; + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function basePayload(): Record { + const now = nowSeconds(); + return { + aud: AUDIENCE, + iss: `https://login.microsoftonline.com/${TENANT}/v2.0`, + tid: TENANT, + oid: SUBJECT, + name: "Ada Lovelace", + preferred_username: "ada@example.com", + email_verified: true, + iat: now, + nbf: now, + exp: now + 3600, + }; +} + +async function sign( + payload: Record, + key: KeyLike = privateKey, +): Promise { + return new SignJWT(payload) + .setProtectedHeader({ alg: "RS256", kid: KEY_ID }) + .sign(key); +} + +function makeJwks( + issuer: unknown = KEY_ISSUER, + includeIssuer = true, + resolve?: JWTVerifyGetKey, +): EntraJwks { + const key = { + ...publicJwk, + alg: "RS256", + use: "sig", + kid: KEY_ID, + ...(includeIssuer ? { issuer } : {}), + }; + const jwks = { keys: [key] }; + return { + resolve: resolve ?? createLocalJWKSet(jwks), + getCurrentJwks: () => jwks, + }; +} + +function makeProvider( + issuer: unknown = KEY_ISSUER, + includeIssuer = true, +): EntraIdAuthProvider { + return new EntraIdAuthProvider({ + authority: AUTHORITY, + audience: AUDIENCE, + jwks: makeJwks(issuer, includeIssuer), + }); +} + +beforeAll(async () => { + ({ privateKey, publicKey } = await generateKeyPair("RS256", { + extractable: true, + })); + ({ privateKey: wrongPrivateKey } = await generateKeyPair("RS256", { + extractable: true, + })); + publicJwk = await exportJWK(publicKey); +}); + +describe("EntraIdAuthProvider.verifyAccessToken", () => { + it("verifies a valid token and extracts identity claims", async () => { + const token = await sign(basePayload()); + const identity = await makeProvider().verifyAccessToken(token); + + expect(identity).toEqual({ + idp: "entra", + idpTenant: TENANT, + idpSubject: SUBJECT, + email: "ada@example.com", + displayName: "Ada Lovelace", + emailVerified: true, + }); + }); + + it("retains issuer metadata from the cached remote JWKS", async () => { + const jwks = makeJwks().getCurrentJwks(); + if (!jwks) throw new Error("Test JWKS is unavailable"); + + let requestCount = 0; + const server = createServer((_request, response) => { + requestCount += 1; + response + .writeHead(200, { "content-type": "application/json" }) + .end(JSON.stringify(jwks)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Test JWKS server has no TCP address"); + } + const provider = new EntraIdAuthProvider({ + authority: AUTHORITY, + audience: AUDIENCE, + jwksUri: `http://127.0.0.1:${address.port}/keys`, + }); + const token = await sign(basePayload()); + + await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({ + idpTenant: TENANT, + }); + await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({ + idpTenant: TENANT, + }); + expect(requestCount).toBe(1); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it("prefers the `email` claim over `preferred_username`", async () => { + const token = await sign({ + ...basePayload(), + email: "ada.primary@example.com", + }); + const identity = await makeProvider().verifyAccessToken(token); + expect(identity.email).toBe("ada.primary@example.com"); + }); + + it("rejects a token signed by a different key (bad signature)", async () => { + const token = await sign(basePayload(), wrongPrivateKey); + await expect(makeProvider().verifyAccessToken(token)).rejects.toMatchObject( + { name: "AuthError", code: "invalid_token" }, + ); + }); + + it.each(["ERR_JWKS_TIMEOUT", "ECONNRESET"])( + "retries one transient JWKS retrieval failure (%s)", + async (code) => { + const token = await sign(basePayload()); + const jwks = vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error("JWKS unavailable"), { code })) + .mockResolvedValueOnce(publicKey); + const provider = new EntraIdAuthProvider({ + authority: AUTHORITY, + audience: AUDIENCE, + jwks: makeJwks(KEY_ISSUER, true, jwks), + }); + + await expect(provider.verifyAccessToken(token)).resolves.toMatchObject({ + idpSubject: SUBJECT, + }); + expect(jwks).toHaveBeenCalledTimes(2); + }, + ); + + it("returns service_unavailable after the bounded JWKS retry is exhausted", async () => { + const token = await sign(basePayload()); + const error = Object.assign(new Error("JWKS request timed out"), { + code: "ERR_JWKS_TIMEOUT", + }); + const jwks = vi.fn().mockRejectedValue(error); + const provider = new EntraIdAuthProvider({ + authority: AUTHORITY, + audience: AUDIENCE, + jwks: makeJwks(KEY_ISSUER, true, jwks), + }); + + await expect(provider.verifyAccessToken(token)).rejects.toMatchObject({ + name: "AuthError", + code: "service_unavailable", + }); + expect(jwks).toHaveBeenCalledTimes(2); + }); + + it("does not retry a JWKS key-selection failure", async () => { + const token = await sign(basePayload()); + const error = Object.assign(new Error("no applicable key found"), { + code: "ERR_JWKS_NO_MATCHING_KEY", + }); + const jwks = vi.fn().mockRejectedValue(error); + const provider = new EntraIdAuthProvider({ + authority: AUTHORITY, + audience: AUDIENCE, + jwks: makeJwks(KEY_ISSUER, true, jwks), + }); + + await expect(provider.verifyAccessToken(token)).rejects.toMatchObject({ + name: "AuthError", + code: "invalid_token", + }); + expect(jwks).toHaveBeenCalledOnce(); + }); + + it("rejects an expired token", async () => { + const now = nowSeconds(); + const token = await sign({ + ...basePayload(), + iat: now - 7200, + nbf: now - 7200, + exp: now - 3600, + }); + await expect(makeProvider().verifyAccessToken(token)).rejects.toMatchObject( + { name: "AuthError", code: "expired_token" }, + ); + }); + + it("rejects a token with the wrong audience", async () => { + const token = await sign({ ...basePayload(), aud: "some-other-api" }); + await expect(makeProvider().verifyAccessToken(token)).rejects.toMatchObject( + { name: "AuthError", code: "invalid_audience" }, + ); + }); + + it("rejects a token whose issuer does not match its tenant", async () => { + const token = await sign({ + ...basePayload(), + iss: `https://login.microsoftonline.com/${OTHER_TENANT}/v2.0`, + }); + await expect(makeProvider().verifyAccessToken(token)).rejects.toMatchObject( + { name: "AuthError", code: "invalid_issuer" }, + ); + }); + + it("accepts an exact signing key issuer for the token tenant", async () => { + const token = await sign(basePayload()); + const identity = await makeProvider( + `https://login.microsoftonline.com/${TENANT}/v2.0`, + ).verifyAccessToken(token); + + expect(identity.idpTenant).toBe(TENANT); + }); + + it("validates the issuer of the selected key, not unrelated JWKS entries", async () => { + const jwks = makeJwks(); + jwks.getCurrentJwks()?.keys.push({ + ...publicJwk, + alg: "RS256", + use: "sig", + kid: "unrelated-key", + issuer: `https://login.microsoftonline.com/${OTHER_TENANT}/v2.0`, + }); + const provider = new EntraIdAuthProvider({ + authority: AUTHORITY, + audience: AUDIENCE, + jwks, + }); + + await expect(provider.verifyAccessToken(await sign(basePayload()))).resolves + .toMatchObject({ idpTenant: TENANT }); + }); + + it("rejects a signing key restricted to another tenant", async () => { + const token = await sign(basePayload()); + await expect( + makeProvider( + `https://login.microsoftonline.com/${OTHER_TENANT}/v2.0`, + ).verifyAccessToken(token), + ).rejects.toMatchObject({ + name: "AuthError", + code: "invalid_issuer", + }); + }); + + it.each([ + ["missing", undefined, false], + ["empty", "", true], + ["non-string", 42, true], + ])( + "rejects a selected signing key with %s issuer metadata", + async (_description, issuer, includeIssuer) => { + const token = await sign(basePayload()); + await expect( + makeProvider(issuer, includeIssuer).verifyAccessToken(token), + ).rejects.toMatchObject({ + name: "AuthError", + code: "invalid_issuer", + }); + }, + ); + + it("rejects a token missing the `oid` claim", async () => { + const payload = basePayload(); + delete payload.oid; + const token = await sign(payload); + await expect( + makeProvider().verifyAccessToken(token), + ).rejects.toBeInstanceOf(AuthError); + await expect(makeProvider().verifyAccessToken(token)).rejects.toMatchObject( + { code: "missing_claim" }, + ); + }); + + it("accepts any tenant (multi-tenant) as long as issuer matches tid", async () => { + const token = await sign({ + ...basePayload(), + tid: OTHER_TENANT, + iss: `https://login.microsoftonline.com/${OTHER_TENANT}/v2.0`, + }); + const identity = await makeProvider().verifyAccessToken(token); + expect(identity.idpTenant).toBe(OTHER_TENANT); + }); + + it("validates a self-hosted issuer via issuerTemplate (entra-local)", async () => { + // The entra-local emulator mints `iss: https://localhost:8443/{tid}/v2.0`, + // which does not match the Entra-cloud default template. + const provider = new EntraIdAuthProvider({ + authority: "https://localhost:8443/common", + audience: AUDIENCE, + issuerTemplate: "https://localhost:8443/{tenantid}/v2.0", + jwks: makeJwks("https://localhost:8443/{tenantid}/v2.0"), + }); + const token = await sign({ + ...basePayload(), + iss: `https://localhost:8443/${TENANT}/v2.0`, + }); + const identity = await provider.verifyAccessToken(token); + expect(identity.idpTenant).toBe(TENANT); + + // The same token is rejected by a provider using the cloud default template. + await expect( + makeProvider().verifyAccessToken(token), + ).rejects.toMatchObject({ name: "AuthError", code: "invalid_issuer" }); + }); +}); diff --git a/packages/shared/src/auth/entra.ts b/packages/shared/src/auth/entra.ts new file mode 100644 index 000000000..be7783aaa --- /dev/null +++ b/packages/shared/src/auth/entra.ts @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + createRemoteJWKSet, + jwksCache, + jwtVerify, + type JWK, + type JSONWebKeySet, + type JWKSCacheInput, + type JWTHeaderParameters, + type JWTPayload, + type JWTVerifyGetKey, + type JWTVerifyResult, +} from "jose"; +import { AuthError, type AuthProvider, type VerifiedIdentity } from "./types.js"; + +interface EntraJwk extends JWK { + issuer?: unknown; +} + +interface EntraJsonWebKeySet extends JSONWebKeySet { + keys: EntraJwk[]; +} + +/** A key resolver paired with the metadata used to select its verification key. */ +export interface EntraJwks { + resolve: JWTVerifyGetKey; + getCurrentJwks(): EntraJsonWebKeySet | undefined; +} + +/** Default Entra v2.0 per-tenant issuer template. `{tenantid}` is substituted. */ +const DEFAULT_ISSUER_TEMPLATE = + "https://login.microsoftonline.com/{tenantid}/v2.0"; + +const TRANSIENT_NETWORK_ERROR_CODES = new Set([ + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETUNREACH", + "ENOTFOUND", + "EAI_AGAIN", + "ETIMEDOUT", + "ERR_JWKS_TIMEOUT", +]); + +const JWKS_KEY_SELECTION_ERROR_CODES = new Set([ + "ERR_JWKS_NO_MATCHING_KEY", + "ERR_JWKS_MULTIPLE_MATCHING_KEYS", +]); + +export interface EntraIdAuthProviderOptions { + /** + * Expected audience — the API app registration client id (a GUID) and/or its + * `api://` Application ID URI. Pass an array to accept both. + */ + audience: string | string[]; + /** + * OIDC authority, e.g. `https://login.microsoftonline.com/common` or + * `.../organizations`. Used to derive the JWKS endpoint. Multi-tenant: any + * tenant is accepted and the issuer is validated per-tenant against + * {@link EntraIdAuthProviderOptions.issuerTemplate}. + */ + authority: string; + /** Explicit JWKS URI. Defaults to `${authority}/discovery/v2.0/keys`. */ + jwksUri?: string; + /** Issuer template with a `{tenantid}` placeholder. Defaults to Entra v2.0. */ + issuerTemplate?: string; + /** Injectable metadata-aware key source for tests (defaults to a cached remote JWKS). */ + jwks?: EntraJwks; +} + +/** + * {@link AuthProvider} backed by Microsoft Entra ID. + * + * Verifies RS256 access tokens against the tenant JWKS, checks audience and a + * per-tenant issuer (no `tid` pinning — any tenant is accepted), and extracts + * identity claims. Never inspects roles/groups. + */ +export class EntraIdAuthProvider implements AuthProvider { + readonly id = "entra"; + + private readonly audience: string | string[]; + private readonly issuerTemplate: string; + private readonly resolveKey: JWTVerifyGetKey; + private readonly getCurrentJwks: () => EntraJsonWebKeySet | undefined; + + constructor(options: EntraIdAuthProviderOptions) { + this.audience = options.audience; + this.issuerTemplate = options.issuerTemplate ?? DEFAULT_ISSUER_TEMPLATE; + + const jwksUri = + options.jwksUri ?? + `${options.authority.replace(/\/+$/, "")}/discovery/v2.0/keys`; + const jwks = options.jwks ?? createRemoteEntraJwks(new URL(jwksUri)); + this.resolveKey = withJwksRetrievalRetry(jwks.resolve); + this.getCurrentJwks = () => jwks.getCurrentJwks(); + } + + async verifyAccessToken(token: string): Promise { + const { payload, protectedHeader } = + await this.verifySignatureAndClaims(token); + + const idpTenant = asString(payload.tid); + if (!idpTenant) { + throw new AuthError("missing_claim", "Token is missing the `tid` claim"); + } + const idpSubject = asString(payload.oid); + if (!idpSubject) { + throw new AuthError("missing_claim", "Token is missing the `oid` claim"); + } + const tokenExpiresAt = payload.exp; + if (typeof tokenExpiresAt !== "number") { + throw new AuthError("missing_claim", "Token is missing the `exp` claim"); + } + + // Multi-tenant issuer validation: any tenant is accepted, but the issuer + // must match the per-tenant v2.0 template for the tenant the token claims. + const expectedIssuer = this.issuerTemplate.replace("{tenantid}", idpTenant); + if (payload.iss !== expectedIssuer) { + throw new AuthError( + "invalid_issuer", + `Unexpected token issuer: ${String(payload.iss)}`, + ); + } + validateSigningKeyIssuer( + this.getCurrentJwks(), + protectedHeader, + expectedIssuer, + idpTenant, + ); + + const email = + asString(payload.email) ?? asString(payload.preferred_username); + const displayName = asString(payload.name); + const emailVerified = + typeof payload.email_verified === "boolean" + ? payload.email_verified + : undefined; + + return { + idp: this.id, + idpTenant, + idpSubject, + email, + displayName, + emailVerified, + }; + } + + private async verifySignatureAndClaims( + token: string, + ): Promise> { + try { + return await jwtVerify(token, this.resolveKey, { + audience: this.audience, + algorithms: ["RS256"], + requiredClaims: ["exp"], + }); + } catch (err) { + if (err instanceof AuthError) { + throw err; + } + + const code = asString((err as { code?: unknown }).code) ?? ""; + const message = err instanceof Error ? err.message : String(err); + + if (code === "ERR_JWT_EXPIRED") { + throw new AuthError("expired_token", "Access token has expired"); + } + if ( + code === "ERR_JWT_CLAIM_VALIDATION_FAILED" && + asString((err as { claim?: unknown }).claim) === "aud" + ) { + // jose raises this for a failed audience (and other) claim checks. + throw new AuthError("invalid_audience", message); + } + throw new AuthError( + "invalid_token", + `Token verification failed: ${message}`, + ); + } + } +} + +function createRemoteEntraJwks(url: URL): EntraJwks { + // This closure-private cache exposes the fetched metadata without allowing + // callers to replace the trusted JWKS contents. + const cache: JWKSCacheInput = {}; + const remoteJwks = createRemoteJWKSet(url, { [jwksCache]: cache }); + return { + resolve: remoteJwks, + getCurrentJwks: () => ("jwks" in cache ? cache.jwks : undefined), + }; +} + +function validateSigningKeyIssuer( + jwks: EntraJsonWebKeySet | undefined, + protectedHeader: JWTHeaderParameters, + tokenIssuer: string, + tenantId: string, +): void { + const matchingKeys = + jwks?.keys.filter((jwk) => isMatchingVerificationJwk(jwk, protectedHeader)) ?? + []; + if (matchingKeys.length !== 1) { + throw new AuthError( + "invalid_issuer", + "Unable to identify signing key issuer metadata", + ); + } + + const keyIssuer = asString(matchingKeys[0].issuer); + if ( + !keyIssuer || + keyIssuer.replaceAll("{tenantid}", tenantId) !== tokenIssuer + ) { + throw new AuthError( + "invalid_issuer", + "Signing key issuer does not match token issuer", + ); + } +} + +function isMatchingVerificationJwk( + jwk: EntraJwk, + protectedHeader: JWTHeaderParameters, +): boolean { + if (jwk.kty !== "RSA") return false; + if ( + typeof protectedHeader.kid === "string" && + jwk.kid !== protectedHeader.kid + ) { + return false; + } + if (typeof jwk.alg === "string" && jwk.alg !== protectedHeader.alg) { + return false; + } + if (typeof jwk.use === "string" && jwk.use !== "sig") { + return false; + } + return !Array.isArray(jwk.key_ops) || jwk.key_ops.includes("verify"); +} + +function withJwksRetrievalRetry(jwks: JWTVerifyGetKey): JWTVerifyGetKey { + return async (protectedHeader, token) => { + try { + return await jwks(protectedHeader, token); + } catch (err) { + if (isJwksKeySelectionError(err)) { + throw err; + } + if (!isTransientJwksRetrievalError(err)) { + throw jwksUnavailableError(); + } + } + + try { + return await jwks(protectedHeader, token); + } catch (err) { + if (isJwksKeySelectionError(err)) { + throw err; + } + throw jwksUnavailableError(); + } + }; +} + +function isJwksKeySelectionError(err: unknown): boolean { + return JWKS_KEY_SELECTION_ERROR_CODES.has(errorCode(err)); +} + +function isTransientJwksRetrievalError(err: unknown): boolean { + const code = errorCode(err); + if (TRANSIENT_NETWORK_ERROR_CODES.has(code)) { + return true; + } + + if ( + err instanceof Error && + (err.name === "AbortError" || + err.name === "TimeoutError" || + /expected 200 OK from the JSON Web Key Set HTTP response/i.test( + err.message, + )) + ) { + return true; + } + + return ( + typeof err === "object" && + err !== null && + "cause" in err && + isTransientJwksRetrievalError(err.cause) + ); +} + +function errorCode(err: unknown): string { + if (typeof err !== "object" || err === null || !("code" in err)) { + return ""; + } + return asString(err.code) ?? ""; +} + +function jwksUnavailableError(): AuthError { + return new AuthError( + "service_unavailable", + "Authentication key service is unavailable", + ); +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} diff --git a/packages/shared/src/auth/index.ts b/packages/shared/src/auth/index.ts new file mode 100644 index 000000000..16d70d859 --- /dev/null +++ b/packages/shared/src/auth/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./types.js"; +export * from "./entra.js"; +export * from "./claims-enricher.js"; +export * from "./user-schema.js"; +export * from "./config.js"; diff --git a/packages/shared/src/auth/types.ts b/packages/shared/src/auth/types.ts new file mode 100644 index 000000000..41a00ea87 --- /dev/null +++ b/packages/shared/src/auth/types.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Pluggable identity-provider (IdP) abstraction. + * + * An {@link AuthProvider} verifies a bearer access token and returns identity + * only — it never reads IdP-side roles or groups. Authorization (roles / + * permissions) is owned by Scope and is intentionally NOT part of this + * milestone; see docs/architecture/auth-rbac.md. + */ + +/** Identity extracted from a verified access token. */ +export interface VerifiedIdentity { + /** Provider id, e.g. "entra". */ + idp: string; + /** IdP tenant id (Entra `tid`). Part of the durable identity key. */ + idpTenant: string; + /** Stable, IdP-unique subject within a tenant (Entra `oid`). */ + idpSubject: string; + /** Advisory email (Entra `email` / `preferred_username`). Never an authz input. */ + email?: string; + /** Display name (Entra `name`). */ + displayName?: string; + /** Whether the IdP asserted the email as verified. */ + emailVerified?: boolean; +} + +/** Backend-side token verifier. Decouples Scope from IdP specifics. */ +export interface AuthProvider { + /** Provider id, e.g. "entra". */ + readonly id: string; + /** Verify a bearer access token. Throws {@link AuthError} on any failure. */ + verifyAccessToken(token: string): Promise; +} + +/** + * IdP configuration the CLI and Portal need. Kept here as a shared, reviewable + * shape so a future IdP swap is a config change, not a code change. + */ +export interface AuthClientConfig { + provider: string; + authority: string; + clientId: string; + scopes: string[]; + audience: string; +} + +/** Reasons an {@link AuthError} can be raised (mapped to HTTP status upstream). */ +export type AuthErrorCode = + | "invalid_token" + | "expired_token" + | "invalid_audience" + | "invalid_issuer" + | "missing_claim" + | "not_configured" + | "service_unavailable"; + +/** Error thrown for any authentication failure. */ +export class AuthError extends Error { + readonly code: AuthErrorCode; + + constructor(code: AuthErrorCode, message: string) { + super(message); + this.name = "AuthError"; + this.code = code; + } +} + +/** Profile fields resolved for a user (from token claims today; Graph later). */ +export interface UserProfile { + email?: string; + displayName?: string; + emailVerified?: boolean; +} + +/** + * Seam for resolving a user's profile. The default implementation + * ({@link ClaimsProfileEnricher}) reads token claims — no network call, no + * client secret. A future `GraphProfileEnricher` (On-Behalf-Of → Microsoft + * Graph) implements the same interface and is dropped in via config, without + * touching call sites. + */ +export interface ProfileEnricher { + /** Enricher id, e.g. "claims" or "graph". */ + readonly id: string; + /** Resolve the profile for a verified identity. `rawToken` enables OBO later. */ + enrich(identity: VerifiedIdentity, rawToken: string): Promise; +} diff --git a/packages/shared/src/auth/user-schema.test.ts b/packages/shared/src/auth/user-schema.test.ts new file mode 100644 index 000000000..398cb14b2 --- /dev/null +++ b/packages/shared/src/auth/user-schema.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { + ANONYMOUS_USER_ID, + SYSTEM_USER_ID, + UserDocumentSchema, +} from "./user-schema.js"; + +describe("UserDocumentSchema", () => { + it("parses a full document", () => { + const now = new Date(); + const doc = UserDocumentSchema.parse({ + _id: "user-uuid", + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", + email: "ada@example.com", + emailVerified: true, + displayName: "Ada Lovelace", + role: "admin", + createdAt: now, + updatedAt: now, + lastLoginAt: now, + }); + expect(doc.role).toBe("admin"); + expect(doc.displayName).toBe("Ada Lovelace"); + }); + + it("defaults role to 'user'", () => { + const now = new Date(); + const doc = UserDocumentSchema.parse({ + _id: "user-uuid", + idp: "entra", + idpTenant: "tenant-1", + idpSubject: "subject-1", + createdAt: now, + updatedAt: now, + }); + expect(doc.role).toBe("user"); + expect(doc.email).toBeUndefined(); + }); + + it("rejects a document missing identity fields", () => { + const now = new Date(); + const result = UserDocumentSchema.safeParse({ + _id: "user-uuid", + idp: "entra", + createdAt: now, + updatedAt: now, + }); + expect(result.success).toBe(false); + }); + + it("exposes reserved id sentinels", () => { + expect(SYSTEM_USER_ID).toBe("system"); + expect(ANONYMOUS_USER_ID).toBe("anonymous"); + }); +}); diff --git a/packages/shared/src/auth/user-schema.ts b/packages/shared/src/auth/user-schema.ts new file mode 100644 index 000000000..8a267dd2a --- /dev/null +++ b/packages/shared/src/auth/user-schema.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; + +/** + * The `users` collection document. + * + * Holds identity plus a deliberately loose `role` string only. The + * role/permission model is expected to change substantially, so no role union + * or permission types are defined here yet. `permissionsAdd` / `permissionsRemove` + * exist for forward-compatibility and are unused today. + */ +export const UserDocumentSchema = z.object({ + /** Scope User ID — an app-owned UUID. Referenced by `ownerId` everywhere later. */ + _id: z.string(), + /** Provider id, e.g. "entra". */ + idp: z.string(), + /** IdP tenant id (Entra `tid`). */ + idpTenant: z.string(), + /** Stable, IdP-unique subject within the tenant (Entra `oid`). */ + idpSubject: z.string(), + email: z.string().optional(), + emailVerified: z.boolean().optional(), + displayName: z.string().optional(), + /** Loose role string (default "user"). Intentionally NOT a union yet. */ + role: z.string().default("user"), + /** Forward-compat, unused today. */ + permissionsAdd: z.array(z.string()).optional(), + /** Forward-compat, unused today. */ + permissionsRemove: z.array(z.string()).optional(), + createdAt: z.date(), + updatedAt: z.date(), + lastLoginAt: z.date().optional(), + /** When set, the user is disabled (treated as 403 by the middleware). */ + disabledAt: z.date().optional(), +}); + +export type UserDocument = z.infer; + +/** Reserved Scope User ID sentinel — never assigned to a live principal. */ +export const SYSTEM_USER_ID = "system"; +/** Reserved Scope User ID for the unauthenticated principal. */ +export const ANONYMOUS_USER_ID = "anonymous"; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 22f6d2ddd..9de8268ea 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -26,5 +26,6 @@ export * from "./devproxy/index.js"; export * from "./har/index.js"; export * from "./utils/index.js"; export * from "./schemas/index.js"; +export * from "./auth/index.js"; export * from "./cursor.js"; export * from "./run-duration.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd4b51846..cecd95e10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -626,38 +626,6 @@ importers: specifier: ^5.8.3 version: 5.9.3 - apps/version-checkers/vscode-electron: - dependencies: - dotenv: - specifier: 16.6.1 - version: 16.6.1 - version-checking: - specifier: workspace:* - version: link:../../../packages/version-checking - devDependencies: - tsx: - specifier: ^4.19.4 - version: 4.21.0 - typescript: - specifier: ^5.8.3 - version: 5.9.3 - - apps/version-checkers/vscode-web: - dependencies: - dotenv: - specifier: 16.6.1 - version: 16.6.1 - version-checking: - specifier: workspace:* - version: link:../../../packages/version-checking - devDependencies: - tsx: - specifier: ^4.19.4 - version: 4.21.0 - typescript: - specifier: ^5.8.3 - version: 5.9.3 - apps/workers/coder-acp-claude-code: dependencies: '@agentclientprotocol/sdk': @@ -804,24 +772,6 @@ importers: specifier: ^5.6.2 version: 5.9.3 - packages/copilot-driver-ext: - devDependencies: - '@types/node': - specifier: 22.x - version: 22.19.10 - '@types/vscode': - specifier: ^1.103.0 - version: 1.110.0 - '@vscode/vsce': - specifier: ^3.6.0 - version: 3.7.1 - esbuild: - specifier: ^0.28.1 - version: 0.28.1 - typescript: - specifier: ^5.6.2 - version: 5.9.3 - packages/db-migrations: dependencies: '@azure/identity': @@ -921,6 +871,9 @@ importers: ioredis: specifier: ^5.4.1 version: 5.9.2 + jose: + specifier: ^5.9.6 + version: 5.10.0 mongodb: specifier: ^6.12.0 version: 6.21.0 @@ -1051,12 +1004,6 @@ packages: peerDependencies: zod: ^4.0.0 - '@azu/format-text@1.0.2': - resolution: {integrity: sha512-Swi4N7Edy1Eqq82GxgEECXSSLyn6GOb5htRFPzBDdUkECGXtlf12ynO5oJSpWKPwCaUssOu7NfhDcCWpIC6Ywg==} - - '@azu/style-format@1.0.1': - resolution: {integrity: sha512-AHcTojlNBdD/3/KxIKlg8sxIWHfOtQszLvOpagLTO+bjC3u7SAszu1lf//u7JJC50aUSH+BVWDD/KvaA6Gfn5g==} - '@azure-rest/ai-inference@1.0.0-beta.6': resolution: {integrity: sha512-j5FrJDTHu2P2+zwFVe5j2edasOIhqkFj+VkDjbhGkQuOoIAByF0egRkgs0G1k03HyJ7bOOT9BkRF7MIgr/afhw==} engines: {node: '>=18.0.0'} @@ -1284,7 +1231,7 @@ packages: engines: {node: '>=6.9.0'} '@babel/runtime@7.29.7': - resolution: {integrity: sha1-EgIkUMRaTabY2Ch7GKT/Ldsj92g=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@babel/runtime/-/runtime-7.29.7.tgz} + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} '@babel/template@7.28.6': @@ -1319,7 +1266,7 @@ packages: engines: {node: '>=18'} '@colors/colors@1.5.0': - resolution: {integrity: sha1-u1BFecHK6SPmV2pPXaQ9Jfl729k=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@colors/colors/-/colors-1.5.0.tgz} + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} '@colors/colors@1.6.0': @@ -1330,166 +1277,166 @@ packages: resolution: {integrity: sha512-3Xgkay0Hubq1hA67IW2vM3YhX4TQgjOFW2TydB0ytAL97zOZI9xr/9vqCjo31bK1qUDNEdlKYLYHd8KR9YSCCw==} '@emnapi/core@1.9.2': - resolution: {integrity: sha1-OHAmXs/8c1LQHq1i2Ng9g1ii0DQ=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@emnapi/core/-/core-1.9.2.tgz} + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} '@emnapi/runtime@1.9.2': - resolution: {integrity: sha1-i0aaPbFggXytsd6QUCEanR6oT6I=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@emnapi/runtime/-/runtime-1.9.2.tgz} + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha1-KP7SGhuhznl8RKBwq8lNQvOuhUg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz} + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha1-egGo0uwvuy2seK2tCbD6eB5Agr4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz} + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha1-tUCifRTkr9BYSWpNvsTT9BTbEQo=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz} + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha1-cEvSl95tdi3lTqu+r79V9nVqvi8=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.28.1.tgz} + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha1-0csWbTSw+/D+irRgpVlPJKN4cB4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.28.1.tgz} + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha1-EDSyZFf8iGNo/mG70J9lP2r6jlQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz} + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha1-ZVVqQyoeTXIDLYIYwZMvzKGkl3I=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz} + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha1-LmHgWS+QMNfj2uGO4l68U1kYrvY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz} + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha1-yV7CiZWe+AecTcqBeh4sS+Zrm9M=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz} + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha1-QLIhdd2gYYLz7oFBGGxf8wTEpxc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz} + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha1-wJoPZ5F1kqwN6JKpvk04FN69Kmw=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz} + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha1-pYD5xnZ5eDOJHlGfx6EzfIr9jbM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz} + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha1-RkUs8yHcf56Rwvp4Cla7Vuec1os=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz} + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha1-QhGzGE3WYI9T3LIuOfXTTuCIUsg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz} + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha1-aXhXwqYcubC2u2ZS5AwdxeHKjl0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz} + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha1-0ZKUPrFGpArExkl9DPe+NbmGvwg=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz} + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha1-rOoDVtoODrwI+Xz3ucLkAeHmSNw=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz} + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha1-bww84MtkxTS3DExF7LLBbTTjXf0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz} + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha1-i813B3oNzjN4tXT+2ybSolO3PTY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz} + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha1-5/sqAemcgwyU5mI82f77TI+1g0c=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz} + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha1-xSkJNy24uG4sVeBaiUADO1Zgo7I=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz} + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha1-xCe5vlpkwmL/mn63C1+7qt9EbGw=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz} + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha1-3JsUe6yi5sSzyFVxdB70hgpIkJc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz} + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha1-zoZtEt8TwV5MmfBzo9Rm9uBkmzo=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz} + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha1-dGjjaS0B1inVlB5dg4F7uA+eObQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz} + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha1-pbwAY/sryrbQ7WPyoVN5WLwmnsY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz} + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha1-EAZO5E9DR7kMmgK0Rrv4CpFjKxI=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz} + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1510,40 +1457,40 @@ packages: resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} '@github/copilot-darwin-arm64@1.0.63': - resolution: {integrity: sha1-JrBj4X6yDxCJqZnJQLqJ43REL3w=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.63.tgz} + resolution: {integrity: sha512-z6CMBxNDlKvT6bvOpqhu4M2bhb0daEbVwSe9SN9WfDUJbt7bpoL7OKKas428iyPSWHoL2WXwxSsy/FjIwSLV6w==} cpu: [arm64] os: [darwin] hasBin: true '@github/copilot-darwin-x64@1.0.63': - resolution: {integrity: sha1-4FoUrHF3rwxZkI9QPFXHVyDk8WM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.63.tgz} + resolution: {integrity: sha512-YKd7cXZgAGxhudzrtWdWh2NS35p2G5bV22Gz3jhEyBTqmq45o4sD4OwO87+UpkvM+3nZpwsHaLd3a+ILYX6OXg==} cpu: [x64] os: [darwin] hasBin: true '@github/copilot-linux-arm64@1.0.63': - resolution: {integrity: sha1-uwl1BhTJsbIAOBht8xC4xjsg8Z0=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.63.tgz} + resolution: {integrity: sha512-A3DOeEfmsJH9j1N+QLc7WXmESBskbezmhDyhyAJcHkw0ngRbKctuWQf/evUHFMh/kgwy1Lr/+9jXJm3NZqr0MA==} cpu: [arm64] os: [linux] libc: [glibc] hasBin: true '@github/copilot-linux-x64@1.0.63': - resolution: {integrity: sha1-mfEe3Z6+0A7S+WjLdIy4jr3Yatk=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.63.tgz} + resolution: {integrity: sha512-OMKfZJRoDaJOV7vuWX/nFPNdLa9/H+nhajdE83v4YT9mKLXr86aWrkXE3pPoDYsKWvgQFHg4APA6oZPao0Fyow==} cpu: [x64] os: [linux] libc: [glibc] hasBin: true '@github/copilot-linuxmusl-arm64@1.0.63': - resolution: {integrity: sha1-MwVeIr4C01C+A4MZAIE8vNleops=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.63.tgz} + resolution: {integrity: sha512-jcIo6B3uHgcOluNfUHp+6atShKKrXYBPLaRyF6aDT699lwI83gW9KTDuEvDs5FDg8qWsWFfOl+al2dkWDYD3CQ==} cpu: [arm64] os: [linux] libc: [musl] hasBin: true '@github/copilot-linuxmusl-x64@1.0.63': - resolution: {integrity: sha1-yawZHApLv+Rt7d33UydDp3RWFgA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.63.tgz} + resolution: {integrity: sha512-BEdBbEF3fG7VqXzuaAY4JtmbdGSkpJFeb2ZQYaMpq7OP3aS7ssGe1cCX8ehZNegcMM/eb4GC6PXNXsvl3X/PAQ==} cpu: [x64] os: [linux] libc: [musl] @@ -1554,13 +1501,13 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} '@github/copilot-win32-arm64@1.0.63': - resolution: {integrity: sha1-uVsv2JKvG4n2Rjs1Hn/UbeJ69e4=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.63.tgz} + resolution: {integrity: sha512-7FqUwOmtoeBoOn4zkKQqRL+WGFwektVRSr5Po2FvPAbKxGXGyFXApZTmRLqVcHhMKDRzMb8KLST1LU1TMTY/wg==} cpu: [arm64] os: [win32] hasBin: true '@github/copilot-win32-x64@1.0.63': - resolution: {integrity: sha1-TX+fgJDAF35LFEk0FTyn+u46mlc=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.63.tgz} + resolution: {integrity: sha512-RC/6y9KHdw/YRCrCEksF2RzbeblfBUNE7bkYZxygaQGYThuv1GeZL2YD2jVqxC2LxKzsUmWGvwEMxerfR6pmeQ==} cpu: [x64] os: [win32] hasBin: true @@ -1675,7 +1622,7 @@ packages: engines: {node: '>=18'} '@napi-rs/wasm-runtime@1.1.4': - resolution: {integrity: sha1-pGu/7cKXUbcXDF0jvB2O6MfjweE=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz} + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -2521,143 +2468,143 @@ packages: engines: {node: ^18.19.0 || >=20.6.0} '@os-theme/darwin-arm64@0.0.8': - resolution: {integrity: sha1-sG3LDuYjSm2+TEIvAzfG+tgR69c=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@os-theme/darwin-arm64/-/darwin-arm64-0.0.8.tgz} + resolution: {integrity: sha512-gMsOs+8Ju396a5yyMWigkbA0dMTxD78U3HzG3mlpiAyn6hfd5dbyI4VGP+sfTB82KGgWLzIhWWTFX5UYY6iX0A==} cpu: [arm64] os: [darwin] '@os-theme/linux-x64@0.0.8': - resolution: {integrity: sha1-3CMQdNJpXo/KYtTjEWsAGPSphME=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@os-theme/linux-x64/-/linux-x64-0.0.8.tgz} + resolution: {integrity: sha512-zvjmBUiSQPjM1RbhpsfCDYMJxW4eLlGmkFPnpteC/03X2lz6CjiX2hfbN2EWLxXjNnIje3Jqaen8IsqEnWrRBg==} cpu: [x64] os: [linux] '@os-theme/win32-x64@0.0.8': - resolution: {integrity: sha1-yMPomy3GVjNS29If0Uwl8WtzR5o=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@os-theme/win32-x64/-/win32-x64-0.0.8.tgz} + resolution: {integrity: sha512-N3yxKNbVl2IBa/ncDuq55QhwqwUjnYLJxDKMEmYeJbLIV950qZNojPw3scXA6PbfxPZfIiRa8iz1pzNg9XxP8w==} cpu: [x64] os: [win32] '@oxc-parser/binding-android-arm-eabi@0.127.0': - resolution: {integrity: sha1-t155YknuIvYy5A6UJ0bEv2SM7pI=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz} + resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] '@oxc-parser/binding-android-arm64@0.127.0': - resolution: {integrity: sha1-4mRGf+OfgAGPYvoNroLbC4AmBEQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz} + resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] '@oxc-parser/binding-darwin-arm64@0.127.0': - resolution: {integrity: sha1-BXbTUQnADcxidyALouyntH4H8bE=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz} + resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] '@oxc-parser/binding-darwin-x64@0.127.0': - resolution: {integrity: sha1-76G6SQdaoxj/VAocL4pEIBdBcgY=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz} + resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] '@oxc-parser/binding-freebsd-x64@0.127.0': - resolution: {integrity: sha1-gXujxQjXUdlNbm/Yavad2qJ9pTE=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz} + resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': - resolution: {integrity: sha1-scMJbGVHcZmEgDFu8Q0eXSntx5s=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz} + resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': - resolution: {integrity: sha1-xEqPEObJA2hYJa6/Eon8IIau1h4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz} + resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@oxc-parser/binding-linux-arm64-gnu@0.127.0': - resolution: {integrity: sha1-YcJFq/q29jBFkVtcnPp9M1rXxEA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz} + resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] '@oxc-parser/binding-linux-arm64-musl@0.127.0': - resolution: {integrity: sha1-NYu9kOXIW2w1El9ab/CE4JtpTAQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz} + resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': - resolution: {integrity: sha1-t+p7Ub9U20xCgZGH92DgadQz2sM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz} + resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': - resolution: {integrity: sha1-OjsQ0WCYjfULu81jHGrznePdRR0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz} + resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] '@oxc-parser/binding-linux-riscv64-musl@0.127.0': - resolution: {integrity: sha1-N4fTfh0KFe4jn1FhAphQAyGzFzA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz} + resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] '@oxc-parser/binding-linux-s390x-gnu@0.127.0': - resolution: {integrity: sha1-txoWy7oRWkaWSY+RSbxUzE4d+c0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz} + resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] '@oxc-parser/binding-linux-x64-gnu@0.127.0': - resolution: {integrity: sha1-cVJ90ChLpyfTWpPIQckRkq8+vew=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz} + resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] '@oxc-parser/binding-linux-x64-musl@0.127.0': - resolution: {integrity: sha1-FoMK+ksAHzSc67k+ErJ45yYByz8=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz} + resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] '@oxc-parser/binding-openharmony-arm64@0.127.0': - resolution: {integrity: sha1-pBxx0knLWX3DVwOOscvjznMkU/g=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz} + resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] '@oxc-parser/binding-wasm32-wasi@0.127.0': - resolution: {integrity: sha1-se/NtDOzDtSjrZEvoD2jg0vUhF0=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz} + resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] '@oxc-parser/binding-win32-arm64-msvc@0.127.0': - resolution: {integrity: sha1-titeMoEmMj1Brh7nrclVN8TEQjo=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz} + resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] '@oxc-parser/binding-win32-ia32-msvc@0.127.0': - resolution: {integrity: sha1-2sMN5pcdvmOqVyK+mkzAcP08ZQ4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz} + resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] '@oxc-parser/binding-win32-x64-msvc@0.127.0': - resolution: {integrity: sha1-ot+HmwgD9ys1CnVnNlzuW4l47fA=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz} + resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2666,110 +2613,110 @@ packages: resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} '@oxc-resolver/binding-android-arm-eabi@11.19.1': - resolution: {integrity: sha1-xEEgqlEE6ZHkqZabsLgWJjpvS8E=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz} + resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] os: [android] '@oxc-resolver/binding-android-arm64@11.19.1': - resolution: {integrity: sha1-ushqnyr9qc1hgeodFUZEjfV5t0A=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz} + resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} cpu: [arm64] os: [android] '@oxc-resolver/binding-darwin-arm64@11.19.1': - resolution: {integrity: sha1-a921t3nN4AA9rg1wO/c04VNpaPE=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz} + resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} cpu: [arm64] os: [darwin] '@oxc-resolver/binding-darwin-x64@11.19.1': - resolution: {integrity: sha1-BVMxQ4pzsh01f975R7nAbskEYQQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz} + resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} cpu: [x64] os: [darwin] '@oxc-resolver/binding-freebsd-x64@11.19.1': - resolution: {integrity: sha1-c16+U7rX6TUlWg6wcvdKQ/+wMsQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz} + resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} cpu: [x64] os: [freebsd] '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': - resolution: {integrity: sha1-IHL2eeWoSF88D+mPO9V9/UZHoZ0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz} + resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} cpu: [arm] os: [linux] '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': - resolution: {integrity: sha1-100KpHOMyfKvniAVy97QpdNGENw=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz} + resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} cpu: [arm] os: [linux] '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': - resolution: {integrity: sha1-BqqaMw7NpGG+aTiWnBxLZ+lpQZ4=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz} + resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} cpu: [arm64] os: [linux] libc: [glibc] '@oxc-resolver/binding-linux-arm64-musl@11.19.1': - resolution: {integrity: sha1-6VHLluf3KouD1jedGdTiYk/KjW4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz} + resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} cpu: [arm64] os: [linux] libc: [musl] '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': - resolution: {integrity: sha1-tzo9lbm3p02KqV3e/Aa2q3PYr8Y=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz} + resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} cpu: [ppc64] os: [linux] libc: [glibc] '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': - resolution: {integrity: sha1-qdKbfdw1GCTKJcSqnebNRc/7XR8=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz} + resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} cpu: [riscv64] os: [linux] libc: [glibc] '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': - resolution: {integrity: sha1-D8u4/woJ4cYcvNKggDlGrsDLmzs=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz} + resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} cpu: [riscv64] os: [linux] libc: [musl] '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': - resolution: {integrity: sha1-G34y32O/Mj5ex2X1NtK+FPulF4c=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz} + resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} cpu: [s390x] os: [linux] libc: [glibc] '@oxc-resolver/binding-linux-x64-gnu@11.19.1': - resolution: {integrity: sha1-SrJ1TxuVIaPQ8AyyUdi2YDIi8vQ=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz} + resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} cpu: [x64] os: [linux] libc: [glibc] '@oxc-resolver/binding-linux-x64-musl@11.19.1': - resolution: {integrity: sha1-kcOumGAEExcmx4uprvYVTaqcI44=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz} + resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} cpu: [x64] os: [linux] libc: [musl] '@oxc-resolver/binding-openharmony-arm64@11.19.1': - resolution: {integrity: sha1-MF/maEP0uiSZ/azxqsADQbWWgZg=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz} + resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} cpu: [arm64] os: [openharmony] '@oxc-resolver/binding-wasm32-wasi@11.19.1': - resolution: {integrity: sha1-Dohesf1ugFgs99+3VmR/vEJ8zhs=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz} + resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} engines: {node: '>=14.0.0'} cpu: [wasm32] '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': - resolution: {integrity: sha1-LhSHHGB1Ug6/0xLrnGoV51Mje5A=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz} + resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} cpu: [arm64] os: [win32] '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': - resolution: {integrity: sha1-toSZLdD8bo+IBTv6hjJ7ky5MxIY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz} + resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} cpu: [ia32] os: [win32] '@oxc-resolver/binding-win32-x64-msvc@11.19.1': - resolution: {integrity: sha1-pI3M3LCDPaSVdXmw4xRmZAeZA2Q=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz} + resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} cpu: [x64] os: [win32] @@ -2777,7 +2724,7 @@ packages: resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} '@protobufjs/aspromise@1.1.2': @@ -3481,195 +3428,146 @@ packages: optional: true '@rollup/rollup-android-arm-eabi@4.60.1': - resolution: {integrity: sha1-BD8UVxYjRSkFLvnhzh2Ef/vp5nQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz} + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] os: [android] '@rollup/rollup-android-arm64@4.60.1': - resolution: {integrity: sha1-Aj4b0UbnUZCH39nosp5M+fjs01w=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz} + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} cpu: [arm64] os: [android] '@rollup/rollup-darwin-arm64@4.60.1': - resolution: {integrity: sha1-Vcy1SHwCQZlUxXp6gGAohdYW4e4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz} + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} cpu: [arm64] os: [darwin] '@rollup/rollup-darwin-x64@4.60.1': - resolution: {integrity: sha1-JUtlQEsUSIyDIl6IuIGTdq1xp4Q=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz} + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} cpu: [x64] os: [darwin] '@rollup/rollup-freebsd-arm64@4.60.1': - resolution: {integrity: sha1-Y3f/OMBSx2/K/7eyco0xcv5nb+Y=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz} + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} cpu: [arm64] os: [freebsd] '@rollup/rollup-freebsd-x64@4.60.1': - resolution: {integrity: sha1-ujkCMJ0Ijq9xObkW8JtxQLKLQG0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz} + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} cpu: [x64] os: [freebsd] '@rollup/rollup-linux-arm-gnueabihf@4.60.1': - resolution: {integrity: sha1-4BG5oUY4Jn5TtEYoboONva9T8Wc=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz} + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': - resolution: {integrity: sha1-C86c6aAJSQq9KP2SLdl+1SExGv4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz} + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': - resolution: {integrity: sha1-b2z7vzJPu0zv8hOr338yL9RdJf8=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz} + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': - resolution: {integrity: sha1-98s+7K6pwVHvdzQq8F84rpJL95U=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz} + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': - resolution: {integrity: sha1-SZv6xrtmn9iLtmQ1e/a+mWoouS8=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz} + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': - resolution: {integrity: sha1-En36wIdkdkOWu+BEU8VF04o6tRg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz} + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': - resolution: {integrity: sha1-anL02VhSqsGDJsW/cIOT6POkG3A=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz} + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': - resolution: {integrity: sha1-uoZ0ZmsA1vkGbLmldxqEMMNNLeY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz} + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': - resolution: {integrity: sha1-F8w4sqceMCVHytKbz3jQ2yYYySI=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz} + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': - resolution: {integrity: sha1-42pB4ti9JHMxvVz8E7jJUdM0VKI=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz} + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': - resolution: {integrity: sha1-FocmXx9L3qBybHYaWMLbmTNgnWg=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz} + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': - resolution: {integrity: sha1-Vqag2QdvKgWpdgMUk7JKIN3MDnc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz} + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': - resolution: {integrity: sha1-vCQOu1uf2NQcqKgMtFhFLowYfg8=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz} + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': - resolution: {integrity: sha1-b4DUigBsSy/6dyTpWj4z9pdYcq8=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz} + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} cpu: [x64] os: [openbsd] '@rollup/rollup-openharmony-arm64@4.60.1': - resolution: {integrity: sha1-j2229w0KSKvYM7JjzW3T5xmcTA4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz} + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} cpu: [arm64] os: [openharmony] '@rollup/rollup-win32-arm64-msvc@4.60.1': - resolution: {integrity: sha1-tomJv6gV0LPU4wLs2QvadEQ4sXc=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz} + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} cpu: [arm64] os: [win32] '@rollup/rollup-win32-ia32-msvc@4.60.1': - resolution: {integrity: sha1-wJjkUzjFDyLxsohHY1TwJbdGKFs=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz} + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} cpu: [ia32] os: [win32] '@rollup/rollup-win32-x64-gnu@4.60.1': - resolution: {integrity: sha1-LJ4VvhVbedBZmZU7FzeykDhC6QM=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz} + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} cpu: [x64] os: [win32] '@rollup/rollup-win32-x64-msvc@4.60.1': - resolution: {integrity: sha1-I7hgET6fh+6gFdH6OkJApStC/NQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz} + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} cpu: [x64] os: [win32] '@scarf/scarf@1.4.0': resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} - '@secretlint/config-creator@10.2.2': - resolution: {integrity: sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ==} - engines: {node: '>=20.0.0'} - - '@secretlint/config-loader@10.2.2': - resolution: {integrity: sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ==} - engines: {node: '>=20.0.0'} - - '@secretlint/core@10.2.2': - resolution: {integrity: sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw==} - engines: {node: '>=20.0.0'} - - '@secretlint/formatter@10.2.2': - resolution: {integrity: sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==} - engines: {node: '>=20.0.0'} - - '@secretlint/node@10.2.2': - resolution: {integrity: sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ==} - engines: {node: '>=20.0.0'} - - '@secretlint/profiler@10.2.2': - resolution: {integrity: sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig==} - - '@secretlint/resolver@10.2.2': - resolution: {integrity: sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==} - - '@secretlint/secretlint-formatter-sarif@10.2.2': - resolution: {integrity: sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ==} - - '@secretlint/secretlint-rule-no-dotenv@10.2.2': - resolution: {integrity: sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg==} - engines: {node: '>=20.0.0'} - - '@secretlint/secretlint-rule-preset-recommend@10.2.2': - resolution: {integrity: sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA==} - engines: {node: '>=20.0.0'} - - '@secretlint/source-creator@10.2.2': - resolution: {integrity: sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw==} - engines: {node: '>=20.0.0'} - - '@secretlint/types@10.2.2': - resolution: {integrity: sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==} - engines: {node: '>=20.0.0'} - - '@sindresorhus/merge-streams@2.3.0': - resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} - engines: {node: '>=18'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3759,7 +3657,7 @@ packages: react: ^18 || ^19 '@testing-library/dom@10.4.1': - resolution: {integrity: sha1-1ET4qInppG6aO087iOD8s++2z5U=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@testing-library/dom/-/dom-10.4.1.tgz} + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} '@testing-library/jest-dom@6.9.1': @@ -3787,29 +3685,14 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@textlint/ast-node-types@15.5.2': - resolution: {integrity: sha512-fCaOxoup5LIyBEo7R1oYWE7V4bSX0KQeHh66twon9e9usaLE3ijgF8QjYsR6joCssdeCHVd0wHm7ppsEyTr6vg==} - - '@textlint/linter-formatter@15.5.2': - resolution: {integrity: sha512-jAw7jWM8+wU9cG6Uu31jGyD1B+PAVePCvnPKC/oov+2iBPKk3ao30zc/Itmi7FvXo4oPaL9PmzPPQhyniPVgVg==} - - '@textlint/module-interop@15.5.2': - resolution: {integrity: sha512-mg6rMQ3+YjwiXCYoQXbyVfDucpTa1q5mhspd/9qHBxUq4uY6W8GU42rmT3GW0V1yOfQ9z/iRrgPtkp71s8JzXg==} - - '@textlint/resolver@15.5.2': - resolution: {integrity: sha512-YEITdjRiJaQrGLUWxWXl4TEg+d2C7+TNNjbGPHPH7V7CCnXm+S9GTjGAL7Q2WSGJyFEKt88Jvx6XdJffRv4HEA==} - - '@textlint/types@15.5.2': - resolution: {integrity: sha512-sJOrlVLLXp4/EZtiWKWq9y2fWyZlI8GP+24rnU5avtPWBIMm/1w97yzKrAqYF8czx2MqR391z5akhnfhj2f/AQ==} - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha1-ErOhsz2x+crU3f8fYEq33QC/Rk4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tybys/wasm-util/-/wasm-util-0.10.2.tgz} + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/adm-zip@0.5.7': resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} '@types/aria-query@5.0.4': - resolution: {integrity: sha1-GjHD03iFDSd42rtjdNA23LpLpwg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/aria-query/-/aria-query-5.0.4.tgz} + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} '@types/aws-lambda@8.10.162': resolution: {integrity: sha1-WJz+zdo6mW6b3yCnOiaggRyvtUk=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/aws-lambda/-/aws-lambda-8.10.162.tgz} @@ -3952,9 +3835,6 @@ packages: '@types/node@22.19.10': resolution: {integrity: sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==} - '@types/normalize-package-data@2.4.4': - resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} - '@types/oracledb@6.5.2': resolution: {integrity: sha1-GUzQ8TQ2+eHnRKbl4FanykAFNtU=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/oracledb/-/oracledb-6.5.2.tgz} @@ -3984,9 +3864,6 @@ packages: '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} - '@types/sarif@2.1.7': - resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} - '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -4044,9 +3921,6 @@ packages: '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@types/vscode@1.110.0': - resolution: {integrity: sha512-AGuxUEpU4F4mfuQjxPPaQVyuOMhs+VT/xRok1jiHVBubHK7lBRvCuOMZG0LKUwxncrPorJ5qq/uil3IdZBd5lA==} - '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} @@ -4133,59 +4007,6 @@ packages: '@vitest/utils@4.1.0': resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} - '@vscode/vsce-sign-alpine-arm64@2.0.6': - resolution: {integrity: sha1-LNJEyvXo7FQ/QvuR1N87kzZByPo=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz} - cpu: [arm64] - os: [alpine] - - '@vscode/vsce-sign-alpine-x64@2.0.6': - resolution: {integrity: sha1-sOgKR5IAHGbif+7iwR6CGtH6FoA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz} - cpu: [x64] - os: [alpine] - - '@vscode/vsce-sign-darwin-arm64@2.0.6': - resolution: {integrity: sha1-S4+hq1XygKmZhb48BvtzDleBDM4=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz} - cpu: [arm64] - os: [darwin] - - '@vscode/vsce-sign-darwin-x64@2.0.6': - resolution: {integrity: sha1-0skYbZUFSYJyy93YODuwOOvPWCA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz} - cpu: [x64] - os: [darwin] - - '@vscode/vsce-sign-linux-arm64@2.0.6': - resolution: {integrity: sha1-s9hWAUQEC5INjG7dQ3QxS1glVIE=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz} - cpu: [arm64] - os: [linux] - - '@vscode/vsce-sign-linux-arm@2.0.6': - resolution: {integrity: sha1-CifEKkrbN+lu7HjNe/o4jNTp++8=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz} - cpu: [arm] - os: [linux] - - '@vscode/vsce-sign-linux-x64@2.0.6': - resolution: {integrity: sha1-reEcru7VJPwWvWxDykmuoAKV3ow=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz} - cpu: [x64] - os: [linux] - - '@vscode/vsce-sign-win32-arm64@2.0.6': - resolution: {integrity: sha1-BoiWgUjgPrOSR5yEkcclBnIb7/w=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz} - cpu: [arm64] - os: [win32] - - '@vscode/vsce-sign-win32-x64@2.0.6': - resolution: {integrity: sha1-dEMO/0HSaBjCP5gmsEXYx1cy6us=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz} - cpu: [x64] - os: [win32] - - '@vscode/vsce-sign@2.0.9': - resolution: {integrity: sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==} - - '@vscode/vsce@3.7.1': - resolution: {integrity: sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==} - engines: {node: '>= 20'} - hasBin: true - '@webcontainer/env@1.1.1': resolution: {integrity: sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==} @@ -4224,13 +4045,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv@8.18.0: - resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - - ansi-escapes@7.3.0: - resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} - engines: {node: '>=18'} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -4244,7 +4058,7 @@ packages: engines: {node: '>=8'} ansi-styles@5.2.0: - resolution: {integrity: sha1-B0SWkK1Fd30ZJKwquy/IiV26g2s=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-5.2.0.tgz} + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} ansi-styles@6.2.3: @@ -4283,9 +4097,6 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -4313,10 +4124,6 @@ packages: ast-v8-to-istanbul@1.0.4: resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} - astral-regex@2.0.0: - resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} - engines: {node: '>=8'} - async-lock@1.4.1: resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} @@ -4338,9 +4145,6 @@ packages: peerDependencies: postcss: ^8.5.10 - azure-devops-node-api@12.5.0: - resolution: {integrity: sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==} - b4a@1.8.0: resolution: {integrity: sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==} peerDependencies: @@ -4368,7 +4172,7 @@ packages: optional: true bare-fs@4.7.2: - resolution: {integrity: sha1-D1mzF+W9vsahSJtRmgaiA80/4DU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bare-fs/-/bare-fs-4.7.2.tgz} + resolution: {integrity: sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==} engines: {bare: '>=1.16.0'} peerDependencies: bare-buffer: '*' @@ -4381,7 +4185,7 @@ packages: engines: {bare: '>=1.14.0'} bare-path@3.0.1: - resolution: {integrity: sha1-wSyBtSeTa2UOh8XQAmTVnvRYCCw=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bare-path/-/bare-path-3.0.1.tgz} + resolution: {integrity: sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==} bare-stream@2.13.1: resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} @@ -4423,10 +4227,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - binaryextensions@6.11.0: - resolution: {integrity: sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw==} - engines: {node: '>=4'} - bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -4434,15 +4234,6 @@ packages: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - boundary@2.0.0: - resolution: {integrity: sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA==} - - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} @@ -4463,9 +4254,6 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} @@ -4532,10 +4320,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -4552,13 +4336,6 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} - cheerio-select@2.1.0: - resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} - - cheerio@1.2.0: - resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} - engines: {node: '>=20.18.1'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4637,10 +4414,6 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -4660,9 +4433,6 @@ packages: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concat-stream@2.0.0: resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} engines: {'0': node >= 6.0} @@ -4713,7 +4483,7 @@ packages: engines: {node: '>= 0.10'} cpu-features@0.0.10: - resolution: {integrity: sha1-mq5TbbJxDHJU1+1nyzy8fSmtecU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cpu-features/-/cpu-features-0.0.10.tgz} + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} engines: {node: '>=10.0.0'} crc-32@1.2.2: @@ -4729,13 +4499,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-select@5.2.2: - resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} - - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -4854,18 +4617,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} - deep-extend@0.6.0: - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} - engines: {node: '>=4.0.0'} - default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -4948,7 +4703,7 @@ packages: engines: {node: '>=6.0.0'} dom-accessibility-api@0.5.16: - resolution: {integrity: sha1-WnQp5gZus2ZNkR4z+w5F3o6whFM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz} + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} dom-accessibility-api@0.6.3: resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} @@ -4956,19 +4711,6 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} - dom-serializer@2.0.0: - resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@5.0.3: - resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} - engines: {node: '>= 4'} - - domutils@3.2.2: - resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dotenv-cli@11.0.0: resolution: {integrity: sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==} hasBin: true @@ -4991,10 +4733,6 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} - editions@6.22.0: - resolution: {integrity: sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ==} - engines: {ecmascript: '>= es5', node: '>=4'} - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -5015,19 +4753,12 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - encoding-sniffer@0.2.1: - resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} - end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} entities@2.2.0: resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} - entities@4.5.0: - resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} - engines: {node: '>=0.12'} - entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -5036,10 +4767,6 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} - environment@1.1.0: - resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} - engines: {node: '>=18'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -5118,10 +4845,6 @@ packages: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} engines: {node: '>=12.0.0'} - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -5140,9 +4863,6 @@ packages: fast-content-type-parse@2.0.1: resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-equals@5.4.0: resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} engines: {node: '>=6.0.0'} @@ -5163,9 +4883,6 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fast-wrap-ansi@0.2.0: resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} @@ -5179,9 +4896,6 @@ packages: fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -5239,17 +4953,13 @@ packages: fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-extra@11.3.4: - resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} - engines: {node: '>=14.14'} - fsevents@2.3.2: - resolution: {integrity: sha1-ilJveLj99GI7cJ4Ll1xSwkwC/Ro=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.2.tgz} + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] fsevents@2.3.3: - resolution: {integrity: sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz} + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] @@ -5296,9 +5006,6 @@ packages: deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. hasBin: true - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5322,10 +5029,6 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} - globby@14.1.0: - resolution: {integrity: sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==} - engines: {node: '>=18'} - google-logging-utils@1.1.3: resolution: {integrity: sha1-F7cfH5XSZtLd01a48AF4Qz8EGxc=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/google-logging-utils/-/google-logging-utils-1.1.3.tgz} engines: {node: '>=14'} @@ -5410,14 +5113,6 @@ packages: headers-polyfill@5.0.1: resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} - hosted-git-info@4.1.0: - resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} - engines: {node: '>=10'} - - hosted-git-info@7.0.2: - resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} - engines: {node: ^16.14.0 || >=18.0.0} - html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -5427,9 +5122,6 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} - htmlparser2@10.1.0: - resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5446,17 +5138,9 @@ packages: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - import-in-the-middle@2.0.6: resolution: {integrity: sha1-GXIze/4CDQX2teAgwTM0VnQ2Mk8=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz} @@ -5468,16 +5152,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - index-to-position@1.2.0: - resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} - engines: {node: '>=18'} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -5587,10 +5264,6 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} - istextorbinary@9.5.0: - resolution: {integrity: sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw==} - engines: {node: '>=4'} - jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -5602,6 +5275,9 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -5612,10 +5288,6 @@ packages: resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} - hasBin: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -5624,20 +5296,11 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha1-rlR4I6wMrYOYZn+M2e9HMPWwH/E=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-bigint/-/json-bigint-1.0.0.tgz} - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true - jsonc-parser@3.3.1: - resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -5651,9 +5314,6 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - keytar@7.9.0: - resolution: {integrity: sha1-TGIlcI9RtQy/d8Wq6BchlkwpGMs=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/keytar/-/keytar-7.9.0.tgz} - kind-of@6.0.3: resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} engines: {node: '>=0.10.0'} @@ -5666,10 +5326,6 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} - leven@3.1.0: - resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} - engines: {node: '>=6'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -5677,9 +5333,6 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkify-it@5.0.2: - resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} - lodash.camelcase@4.3.0: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} @@ -5710,9 +5363,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash.truncate@4.4.2: - resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -5747,17 +5397,13 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - lucide-react@0.469.0: resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 lz-string@1.5.0: - resolution: {integrity: sha1-watQ93iHtxJiEgG6n9Tjpu0JmUE=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lz-string/-/lz-string-1.5.0.tgz} + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true magic-string@0.30.21: @@ -5770,10 +5416,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-it@14.3.0: - resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} - hasBin: true - markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -5826,9 +5468,6 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdurl@2.0.0: - resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} - media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -5957,10 +5596,6 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -5969,9 +5604,6 @@ packages: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@5.1.9: resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} @@ -6071,9 +5703,6 @@ packages: resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} engines: {node: '>= 10.16.0'} - mute-stream@0.0.8: - resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} - mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -6082,16 +5711,13 @@ packages: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} nan@2.25.0: - resolution: {integrity: sha1-k37TReY9lIE2KnlC1JxIYNJ+6r0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nan/-/nan-2.25.0.tgz} + resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==} nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - negotiator@0.6.3: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} @@ -6099,13 +5725,6 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - node-abi@3.89.0: - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} - engines: {node: '>=10'} - - node-addon-api@4.3.0: - resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} - node-domexception@1.0.0: resolution: {integrity: sha1-aIjbRqH3HAt2s/dVUBa2P+ZHZuU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/node-domexception/-/node-domexception-1.0.0.tgz} engines: {node: '>=10.5.0'} @@ -6118,21 +5737,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - node-sarif-builder@3.4.0: - resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} - engines: {node: '>=20'} - - normalize-package-data@6.0.2: - resolution: {integrity: sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g==} - engines: {node: ^16.14.0 || >=18.0.0} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -6188,29 +5796,12 @@ packages: oxc-resolver@11.19.1: resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} - package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - parse-json@8.3.0: - resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} - engines: {node: '>=18'} - - parse-semver@1.1.1: - resolution: {integrity: sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==} - - parse5-htmlparser2-tree-adapter@7.1.0: - resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} - - parse5-parser-stream@7.1.2: - resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} - parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -6243,10 +5834,6 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-type@6.0.0: - resolution: {integrity: sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==} - engines: {node: '>=18'} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -6297,13 +5884,6 @@ packages: engines: {node: '>=18'} hasBin: true - pluralize@2.0.0: - resolution: {integrity: sha512-TqNZzQCD4S42De9IfnnBvILN7HAW7riLqsCyp8lgjXeysyPlX5HhqKAcJHHHb9XskE4/a+7VGC9zzx8Ls0jOAw==} - - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -6375,14 +5955,8 @@ packages: resolution: {integrity: sha1-tGDILLFYdQd4iBmgaqD//bNURpU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postgres-interval/-/postgres-interval-1.2.0.tgz} engines: {node: '>=0.10.0'} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. - hasBin: true - pretty-format@27.5.1: - resolution: {integrity: sha1-IYGHn96lGnpYUfs52SD6pj8B2I4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pretty-format/-/pretty-format-27.5.1.tgz} + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} process-nextick-args@2.0.1: @@ -6416,10 +5990,6 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - punycode.js@2.3.1: - resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} - engines: {node: '>=6'} - punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -6443,13 +6013,6 @@ packages: resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} engines: {node: '>= 0.8'} - rc-config-loader@4.1.4: - resolution: {integrity: sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ==} - - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -6473,7 +6036,7 @@ packages: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} react-is@17.0.2: - resolution: {integrity: sha1-5pHUqOnHiTZWVVOas3J2Kw77VPA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-is/-/react-is-17.0.2.tgz} + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -6554,14 +6117,6 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - read-pkg@9.0.1: - resolution: {integrity: sha512-9viLL4/n1BJUCT1NXVTdS1jtm80yDEgR5T4yCelII49Mbj0v1rZdKqj7zCiYdbB0CuCgdrvHcNogAKTFPBocFA==} - engines: {node: '>=18'} - - read@1.0.7: - resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} - engines: {node: '>=0.8'} - readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -6632,10 +6187,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - require-in-the-middle@8.0.1: resolution: {integrity: sha1-294lh/ZpOYYm1WsgyGirh78BzOQ=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} @@ -6695,26 +6246,13 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sax@1.6.0: - resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} - engines: {node: '>=11.0.0'} - scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - secretlint@10.2.2: - resolution: {integrity: sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg==} - engines: {node: '>=20.0.0'} - hasBin: true - section-matter@1.0.0: resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} engines: {node: '>=4'} - semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} - hasBin: true - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -6784,20 +6322,6 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} - simple-concat@1.0.1: - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} - - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - - slash@5.1.0: - resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} - engines: {node: '>=14.16'} - - slice-ansi@4.0.0: - resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} - engines: {node: '>=10'} - sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -6818,18 +6342,6 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.23: - resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -6925,16 +6437,9 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} - structured-source@4.0.0: - resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} - style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -6962,10 +6467,6 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} - supports-hyperlinks@3.2.0: - resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} - engines: {node: '>=14.18'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -6985,10 +6486,6 @@ packages: os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true - table@6.9.0: - resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} - engines: {node: '>=10.0.0'} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -7026,23 +6523,12 @@ packages: teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} - terminal-link@4.0.0: - resolution: {integrity: sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==} - engines: {node: '>=18'} - testcontainers@12.0.1: resolution: {integrity: sha512-EMjjfMNJf3HlL7V3elkxqKUO1r3CtqNBTdmKGwwma/lOtUGfoWvFJ0WQ/KQf1DHEMnRjLWzW4cXbv/Tndsbcbw==} text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - - textextensions@6.11.0: - resolution: {integrity: sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ==} - engines: {node: '>=4'} - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -7136,9 +6622,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tunnel@0.0.6: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} @@ -7146,10 +6629,6 @@ packages: tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - type-fest@5.6.0: resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} @@ -7158,9 +6637,6 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - typed-rest-client@1.8.11: - resolution: {integrity: sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==} - typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} @@ -7169,17 +6645,11 @@ packages: engines: {node: '>=14.17'} hasBin: true - uc.micro@2.1.0: - resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - uglify-js@3.19.3: - resolution: {integrity: sha1-gjFem7xvKyWIiFis0f/4RBA1t38=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uglify-js/-/uglify-js-3.19.3.tgz} + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} hasBin: true - underscore@1.13.8: - resolution: {integrity: sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==} - undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -7194,14 +6664,6 @@ packages: resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} - unicorn-magic@0.1.0: - resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} - engines: {node: '>=18'} - - unicorn-magic@0.3.0: - resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} - engines: {node: '>=18'} - unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -7229,10 +6691,6 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -7250,9 +6708,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - url-join@4.0.1: - resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} - use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -7289,17 +6744,10 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - version-range@4.15.0: - resolution: {integrity: sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg==} - engines: {node: '>=4'} - vfile-location@5.0.3: resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} @@ -7408,19 +6856,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - whatwg-encoding@3.1.1: - resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} - engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-mimetype@4.0.0: - resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} - engines: {node: '>=18'} - whatwg-url@14.2.0: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} @@ -7478,17 +6917,6 @@ packages: resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} engines: {node: '>=16.0.0'} - xml2js@0.5.0: - resolution: {integrity: sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==} - engines: {node: '>=4.0.0'} - - xmlbuilder@11.0.1: - resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} - engines: {node: '>=4.0'} - - xstate@5.28.0: - resolution: {integrity: sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A==} - xtend@4.0.2: resolution: {integrity: sha1-u3J3n1+kZRhrH0OPZ0+jR/2121Q=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/xtend/-/xtend-4.0.2.tgz} engines: {node: '>=0.4'} @@ -7500,9 +6928,6 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -7520,16 +6945,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yauzl@3.4.0: resolution: {integrity: sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==} engines: {node: '>=12'} - yazl@2.5.1: - resolution: {integrity: sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==} - yazl@3.3.1: resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==} @@ -7599,12 +7018,6 @@ snapshots: openapi3-ts: 4.5.0 zod: 4.3.6 - '@azu/format-text@1.0.2': {} - - '@azu/style-format@1.0.1': - dependencies: - '@azu/format-text': 1.0.2 - '@azure-rest/ai-inference@1.0.0-beta.6': dependencies: '@azure-rest/core-client': 2.5.1 @@ -10433,82 +9846,6 @@ snapshots: '@scarf/scarf@1.4.0': {} - '@secretlint/config-creator@10.2.2': - dependencies: - '@secretlint/types': 10.2.2 - - '@secretlint/config-loader@10.2.2': - dependencies: - '@secretlint/profiler': 10.2.2 - '@secretlint/resolver': 10.2.2 - '@secretlint/types': 10.2.2 - ajv: 8.18.0 - debug: 4.4.3 - rc-config-loader: 4.1.4 - transitivePeerDependencies: - - supports-color - - '@secretlint/core@10.2.2': - dependencies: - '@secretlint/profiler': 10.2.2 - '@secretlint/types': 10.2.2 - debug: 4.4.3 - structured-source: 4.0.0 - transitivePeerDependencies: - - supports-color - - '@secretlint/formatter@10.2.2': - dependencies: - '@secretlint/resolver': 10.2.2 - '@secretlint/types': 10.2.2 - '@textlint/linter-formatter': 15.5.2 - '@textlint/module-interop': 15.5.2 - '@textlint/types': 15.5.2 - chalk: 5.6.2 - debug: 4.4.3 - pluralize: 8.0.0 - strip-ansi: 7.1.2 - table: 6.9.0 - terminal-link: 4.0.0 - transitivePeerDependencies: - - supports-color - - '@secretlint/node@10.2.2': - dependencies: - '@secretlint/config-loader': 10.2.2 - '@secretlint/core': 10.2.2 - '@secretlint/formatter': 10.2.2 - '@secretlint/profiler': 10.2.2 - '@secretlint/source-creator': 10.2.2 - '@secretlint/types': 10.2.2 - debug: 4.4.3 - p-map: 7.0.4 - transitivePeerDependencies: - - supports-color - - '@secretlint/profiler@10.2.2': {} - - '@secretlint/resolver@10.2.2': {} - - '@secretlint/secretlint-formatter-sarif@10.2.2': - dependencies: - node-sarif-builder: 3.4.0 - - '@secretlint/secretlint-rule-no-dotenv@10.2.2': - dependencies: - '@secretlint/types': 10.2.2 - - '@secretlint/secretlint-rule-preset-recommend@10.2.2': {} - - '@secretlint/source-creator@10.2.2': - dependencies: - '@secretlint/types': 10.2.2 - istextorbinary: 9.5.0 - - '@secretlint/types@10.2.2': {} - - '@sindresorhus/merge-streams@2.3.0': {} - '@standard-schema/spec@1.1.0': {} '@storybook/builder-vite@10.4.0(esbuild@0.28.1)(rollup@4.60.1)(storybook@10.4.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@testing-library/dom@10.4.1)(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.5(@types/node@22.19.10)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))': @@ -10633,35 +9970,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@textlint/ast-node-types@15.5.2': {} - - '@textlint/linter-formatter@15.5.2': - dependencies: - '@azu/format-text': 1.0.2 - '@azu/style-format': 1.0.1 - '@textlint/module-interop': 15.5.2 - '@textlint/resolver': 15.5.2 - '@textlint/types': 15.5.2 - chalk: 4.1.2 - debug: 4.4.3 - js-yaml: 4.3.0 - lodash: 4.18.1 - pluralize: 2.0.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - table: 6.9.0 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - - '@textlint/module-interop@15.5.2': {} - - '@textlint/resolver@15.5.2': {} - - '@textlint/types@15.5.2': - dependencies: - '@textlint/ast-node-types': 15.5.2 - '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -10840,8 +10148,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/normalize-package-data@2.4.4': {} - '@types/oracledb@6.5.2': dependencies: '@types/node': 22.19.10 @@ -10874,8 +10180,6 @@ snapshots: '@types/resolve@1.20.6': {} - '@types/sarif@2.1.7': {} - '@types/semver@7.7.1': {} '@types/send@0.17.6': @@ -10950,8 +10254,6 @@ snapshots: '@types/uuid@10.0.0': {} - '@types/vscode@1.110.0': {} - '@types/webidl-conversions@7.0.3': {} '@types/whatwg-mimetype@3.0.2': {} @@ -11080,81 +10382,6 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vscode/vsce-sign-alpine-arm64@2.0.6': - optional: true - - '@vscode/vsce-sign-alpine-x64@2.0.6': - optional: true - - '@vscode/vsce-sign-darwin-arm64@2.0.6': - optional: true - - '@vscode/vsce-sign-darwin-x64@2.0.6': - optional: true - - '@vscode/vsce-sign-linux-arm64@2.0.6': - optional: true - - '@vscode/vsce-sign-linux-arm@2.0.6': - optional: true - - '@vscode/vsce-sign-linux-x64@2.0.6': - optional: true - - '@vscode/vsce-sign-win32-arm64@2.0.6': - optional: true - - '@vscode/vsce-sign-win32-x64@2.0.6': - optional: true - - '@vscode/vsce-sign@2.0.9': - optionalDependencies: - '@vscode/vsce-sign-alpine-arm64': 2.0.6 - '@vscode/vsce-sign-alpine-x64': 2.0.6 - '@vscode/vsce-sign-darwin-arm64': 2.0.6 - '@vscode/vsce-sign-darwin-x64': 2.0.6 - '@vscode/vsce-sign-linux-arm': 2.0.6 - '@vscode/vsce-sign-linux-arm64': 2.0.6 - '@vscode/vsce-sign-linux-x64': 2.0.6 - '@vscode/vsce-sign-win32-arm64': 2.0.6 - '@vscode/vsce-sign-win32-x64': 2.0.6 - - '@vscode/vsce@3.7.1': - dependencies: - '@azure/identity': 4.13.0 - '@secretlint/node': 10.2.2 - '@secretlint/secretlint-formatter-sarif': 10.2.2 - '@secretlint/secretlint-rule-no-dotenv': 10.2.2 - '@secretlint/secretlint-rule-preset-recommend': 10.2.2 - '@vscode/vsce-sign': 2.0.9 - azure-devops-node-api: 12.5.0 - chalk: 4.1.2 - cheerio: 1.2.0 - cockatiel: 3.2.1 - commander: 12.1.0 - form-data: 4.0.6 - glob: 11.1.0 - hosted-git-info: 4.1.0 - jsonc-parser: 3.3.1 - leven: 3.1.0 - markdown-it: 14.3.0 - mime: 1.6.0 - minimatch: 3.1.5 - parse-semver: 1.1.1 - read: 1.0.7 - secretlint: 10.2.2 - semver: 7.7.4 - tmp: 0.2.7 - typed-rest-client: 1.8.11 - url-join: 4.0.1 - xml2js: 0.5.0 - yauzl: 2.10.0 - yazl: 2.5.1 - optionalDependencies: - keytar: 7.9.0 - transitivePeerDependencies: - - supports-color - '@webcontainer/env@1.1.1': {} '@xyflow/react@12.10.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -11199,17 +10426,6 @@ snapshots: agent-base@7.1.4: {} - ajv@8.18.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-escapes@7.3.0: - dependencies: - environment: 1.1.0 - ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -11266,8 +10482,6 @@ snapshots: dependencies: sprintf-js: 1.0.3 - argparse@2.0.1: {} - aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -11296,8 +10510,6 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 - astral-regex@2.0.0: {} - async-lock@1.4.1: {} async@3.2.6: {} @@ -11315,11 +10527,6 @@ snapshots: postcss: 8.5.19 postcss-value-parser: 4.2.0 - azure-devops-node-api@12.5.0: - dependencies: - tunnel: 0.0.6 - typed-rest-client: 1.8.11 - b4a@1.8.0: {} bail@2.0.2: {} @@ -11381,10 +10588,6 @@ snapshots: binary-extensions@2.3.0: {} - binaryextensions@6.11.0: - dependencies: - editions: 6.22.0 - bl@4.1.0: dependencies: buffer: 5.7.1 @@ -11408,15 +10611,6 @@ snapshots: transitivePeerDependencies: - supports-color - boolbase@1.0.0: {} - - boundary@2.0.0: {} - - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.1.1: dependencies: balanced-match: 1.0.2 @@ -11439,8 +10633,6 @@ snapshots: bson@6.10.4: {} - buffer-crc32@0.2.13: {} - buffer-crc32@1.0.0: {} buffer-equal-constant-time@1.0.1: {} @@ -11503,8 +10695,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.6.2: {} - character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -11515,29 +10705,6 @@ snapshots: check-error@2.1.3: {} - cheerio-select@2.1.0: - dependencies: - boolbase: 1.0.0 - css-select: 5.2.2 - css-what: 6.2.2 - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - - cheerio@1.2.0: - dependencies: - cheerio-select: 2.1.0 - dom-serializer: 2.0.0 - domhandler: 5.0.3 - domutils: 3.2.2 - encoding-sniffer: 0.2.1 - htmlparser2: 10.1.0 - parse5: 7.3.0 - parse5-htmlparser2-tree-adapter: 7.1.0 - parse5-parser-stream: 7.1.2 - undici: 7.28.0 - whatwg-mimetype: 4.0.0 - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -11608,8 +10775,6 @@ snapshots: comma-separated-tokens@2.0.3: {} - commander@12.1.0: {} - commander@14.0.3: {} commander@4.1.1: {} @@ -11626,8 +10791,6 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 - concat-map@0.0.1: {} - concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 @@ -11690,16 +10853,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-select@5.2.2: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 5.0.3 - domutils: 3.2.2 - nth-check: 2.1.1 - - css-what@6.2.2: {} - css.escape@1.5.1: {} cssesc@3.0.0: {} @@ -11803,16 +10956,8 @@ snapshots: dependencies: character-entities: 2.0.2 - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - optional: true - deep-eql@5.0.2: {} - deep-extend@0.6.0: - optional: true - default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -11913,24 +11058,6 @@ snapshots: '@babel/runtime': 7.28.6 csstype: 3.2.3 - dom-serializer@2.0.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - entities: 4.5.0 - - domelementtype@2.3.0: {} - - domhandler@5.0.3: - dependencies: - domelementtype: 2.3.0 - - domutils@3.2.2: - dependencies: - dom-serializer: 2.0.0 - domelementtype: 2.3.0 - domhandler: 5.0.3 - dotenv-cli@11.0.0: dependencies: cross-spawn: 7.0.6 @@ -11956,10 +11083,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - editions@6.22.0: - dependencies: - version-range: 4.15.0 - ee-first@1.1.1: {} electron-to-chromium@1.5.286: {} @@ -11972,25 +11095,16 @@ snapshots: encodeurl@2.0.0: {} - encoding-sniffer@0.2.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-encoding: 3.1.1 - end-of-stream@1.4.5: dependencies: once: 1.4.0 entities@2.2.0: {} - entities@4.5.0: {} - entities@6.0.1: {} entities@7.0.1: {} - environment@1.1.0: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -12073,9 +11187,6 @@ snapshots: eventsource@2.0.2: {} - expand-template@2.0.3: - optional: true - expect-type@1.3.0: {} express@4.22.1: @@ -12122,8 +11233,6 @@ snapshots: fast-content-type-parse@2.0.1: {} - fast-deep-equal@3.1.3: {} - fast-equals@5.4.0: {} fast-fifo@1.3.2: {} @@ -12144,8 +11253,6 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.2: {} - fast-wrap-ansi@0.2.0: dependencies: fast-string-width: 3.0.2 @@ -12168,10 +11275,6 @@ snapshots: dependencies: reusify: 1.1.0 - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -12232,12 +11335,6 @@ snapshots: fs-constants@1.0.0: {} - fs-extra@11.3.4: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - fsevents@2.3.2: optional: true @@ -12306,9 +11403,6 @@ snapshots: undici: 6.27.0 yargs: 17.7.2 - github-from-package@0.0.0: - optional: true - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -12341,15 +11435,6 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 - globby@14.1.0: - dependencies: - '@sindresorhus/merge-streams': 2.3.0 - fast-glob: 3.3.3 - ignore: 7.0.5 - path-type: 6.0.0 - slash: 5.1.0 - unicorn-magic: 0.3.0 - google-logging-utils@1.1.3: {} gopd@1.2.0: {} @@ -12513,27 +11598,12 @@ snapshots: '@types/set-cookie-parser': 2.4.10 set-cookie-parser: 3.1.0 - hosted-git-info@4.1.0: - dependencies: - lru-cache: 6.0.0 - - hosted-git-info@7.0.2: - dependencies: - lru-cache: 10.4.3 - html-escaper@2.0.2: {} html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} - htmlparser2@10.1.0: - dependencies: - domelementtype: 2.3.0 - domhandler: 5.0.3 - domutils: 3.2.2 - entities: 7.0.1 - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -12560,14 +11630,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@7.0.5: {} - import-in-the-middle@2.0.6: dependencies: acorn: 8.16.0 @@ -12583,13 +11647,8 @@ snapshots: indent-string@4.0.0: {} - index-to-position@1.2.0: {} - inherits@2.0.4: {} - ini@1.3.8: - optional: true - inline-style-parser@0.2.7: {} internmap@2.0.3: {} @@ -12680,12 +11739,6 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 - istextorbinary@9.5.0: - dependencies: - binaryextensions: 6.11.0 - editions: 6.22.0 - textextensions: 6.11.0 - jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -12698,6 +11751,8 @@ snapshots: jiti@1.21.7: {} + jose@5.10.0: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -12707,28 +11762,14 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: - dependencies: - argparse: 2.0.1 - jsesc@3.1.0: {} json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 - json-schema-traverse@1.0.0: {} - json5@2.2.3: {} - jsonc-parser@3.3.1: {} - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -12755,12 +11796,6 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - keytar@7.9.0: - dependencies: - node-addon-api: 4.3.0 - prebuild-install: 7.1.3 - optional: true - kind-of@6.0.3: {} ky@2.0.2: {} @@ -12769,16 +11804,10 @@ snapshots: dependencies: readable-stream: 2.3.8 - leven@3.1.0: {} - lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} - linkify-it@5.0.2: - dependencies: - uc.micro: 2.1.0 - lodash.camelcase@4.3.0: {} lodash.defaults@4.2.0: {} @@ -12799,8 +11828,6 @@ snapshots: lodash.once@4.1.1: {} - lodash.truncate@4.4.2: {} - lodash@4.18.1: {} log-symbols@4.1.0: @@ -12835,10 +11862,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - lucide-react@0.469.0(react@19.2.4): dependencies: react: 19.2.4 @@ -12859,15 +11882,6 @@ snapshots: dependencies: semver: 7.8.0 - markdown-it@14.3.0: - dependencies: - argparse: 2.0.1 - entities: 4.5.0 - linkify-it: 5.0.2 - mdurl: 2.0.0 - punycode.js: 2.3.1 - uc.micro: 2.1.0 - markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -13025,8 +12039,6 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdurl@2.0.0: {} - media-typer@0.3.0: {} memory-pager@1.5.0: {} @@ -13245,19 +12257,12 @@ snapshots: mimic-fn@2.1.0: {} - mimic-response@3.1.0: - optional: true - min-indent@1.0.1: {} minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.13 - minimatch@5.1.9: dependencies: brace-expansion: 2.1.1 @@ -13346,8 +12351,6 @@ snapshots: concat-stream: 2.0.0 type-is: 1.6.18 - mute-stream@0.0.8: {} - mute-stream@3.0.0: {} mz@2.7.0: @@ -13361,21 +12364,10 @@ snapshots: nanoid@3.3.15: {} - napi-build-utils@2.0.0: - optional: true - negotiator@0.6.3: {} neo-async@2.6.2: {} - node-abi@3.89.0: - dependencies: - semver: 7.7.4 - optional: true - - node-addon-api@4.3.0: - optional: true - node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -13386,23 +12378,8 @@ snapshots: node-releases@2.0.27: {} - node-sarif-builder@3.4.0: - dependencies: - '@types/sarif': 2.1.7 - fs-extra: 11.3.4 - - normalize-package-data@6.0.2: - dependencies: - hosted-git-info: 7.0.2 - semver: 7.7.4 - validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -13511,8 +12488,6 @@ snapshots: - '@emnapi/core' - '@emnapi/runtime' - p-map@7.0.4: {} - package-json-from-dist@1.0.1: {} parse-entities@4.0.2: @@ -13525,25 +12500,6 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 - parse-json@8.3.0: - dependencies: - '@babel/code-frame': 7.29.0 - index-to-position: 1.2.0 - type-fest: 4.41.0 - - parse-semver@1.1.1: - dependencies: - semver: 5.7.2 - - parse5-htmlparser2-tree-adapter@7.1.0: - dependencies: - domhandler: 5.0.3 - parse5: 7.3.0 - - parse5-parser-stream@7.1.2: - dependencies: - parse5: 7.3.0 - parse5@7.3.0: dependencies: entities: 6.0.1 @@ -13570,8 +12526,6 @@ snapshots: path-to-regexp@6.3.0: {} - path-type@6.0.0: {} - pathe@2.0.3: {} pathval@2.0.1: {} @@ -13608,10 +12562,6 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - pluralize@2.0.0: {} - - pluralize@8.0.0: {} - pngjs@7.0.0: {} postcss-import@15.1.0(postcss@8.5.19): @@ -13668,22 +12618,6 @@ snapshots: dependencies: xtend: 4.0.2 - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.1.2 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.89.0 - pump: 3.0.4 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.4 - tunnel-agent: 0.6.0 - optional: true - pretty-format@27.5.1: dependencies: ansi-regex: 5.0.1 @@ -13739,8 +12673,6 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 - punycode.js@2.3.1: {} - punycode@2.3.1: {} qr@0.5.5: {} @@ -13760,23 +12692,6 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - rc-config-loader@4.1.4: - dependencies: - debug: 4.4.3 - js-yaml: 4.3.0 - json5: 2.2.3 - require-from-string: 2.0.2 - transitivePeerDependencies: - - supports-color - - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - optional: true - react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -13895,18 +12810,6 @@ snapshots: dependencies: pify: 2.3.0 - read-pkg@9.0.1: - dependencies: - '@types/normalize-package-data': 2.4.4 - normalize-package-data: 6.0.2 - parse-json: 8.3.0 - type-fest: 4.41.0 - unicorn-magic: 0.1.0 - - read@1.0.7: - dependencies: - mute-stream: 0.0.8 - readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -14031,8 +12934,6 @@ snapshots: require-directory@2.1.1: {} - require-from-string@2.0.2: {} - require-in-the-middle@8.0.1: dependencies: debug: 4.4.3 @@ -14112,29 +13013,13 @@ snapshots: safer-buffer@2.1.2: {} - sax@1.6.0: {} - scheduler@0.27.0: {} - secretlint@10.2.2: - dependencies: - '@secretlint/config-creator': 10.2.2 - '@secretlint/formatter': 10.2.2 - '@secretlint/node': 10.2.2 - '@secretlint/profiler': 10.2.2 - debug: 4.4.3 - globby: 14.1.0 - read-pkg: 9.0.1 - transitivePeerDependencies: - - supports-color - section-matter@1.0.0: dependencies: extend-shallow: 2.0.1 kind-of: 6.0.3 - semver@5.7.2: {} - semver@6.3.1: {} semver@7.7.4: {} @@ -14216,24 +13101,6 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: - optional: true - - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - optional: true - - slash@5.1.0: {} - - slice-ansi@4.0.0: - dependencies: - ansi-styles: 4.3.0 - astral-regex: 2.0.0 - is-fullwidth-code-point: 3.0.0 - sonner@1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 @@ -14249,20 +13116,6 @@ snapshots: dependencies: memory-pager: 1.5.0 - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.23 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.23 - - spdx-license-ids@3.0.23: {} - split-ca@1.0.1: {} sprintf-js@1.0.3: {} @@ -14382,17 +13235,10 @@ snapshots: strip-indent@4.1.1: {} - strip-json-comments@2.0.1: - optional: true - strnum@2.4.1: dependencies: anynum: 1.0.1 - structured-source@4.0.0: - dependencies: - boundary: 2.0.0 - style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -14441,11 +13287,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-hyperlinks@3.2.0: - dependencies: - has-flag: 4.0.0 - supports-color: 7.2.0 - supports-preserve-symlinks-flag@1.0.0: {} swagger-ui-dist@5.32.1: @@ -14459,14 +13300,6 @@ snapshots: systeminformation@5.33.0: {} - table@6.9.0: - dependencies: - ajv: 8.18.0 - lodash.truncate: 4.4.2 - slice-ansi: 4.0.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - tagged-tag@1.0.0: {} tailwind-merge@2.6.1: {} @@ -14555,11 +13388,6 @@ snapshots: - react-native-b4a optional: true - terminal-link@4.0.0: - dependencies: - ansi-escapes: 7.3.0 - supports-hyperlinks: 3.2.0 - testcontainers@12.0.1: dependencies: '@balena/dockerignore': 1.0.2 @@ -14589,12 +13417,6 @@ snapshots: transitivePeerDependencies: - react-native-b4a - text-table@0.2.0: {} - - textextensions@6.11.0: - dependencies: - editions: 6.22.0 - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -14669,17 +13491,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - optional: true - tunnel@0.0.6: {} tweetnacl@0.14.5: {} - type-fest@4.41.0: {} - type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 @@ -14689,23 +13504,13 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - typed-rest-client@1.8.11: - dependencies: - qs: 6.15.2 - tunnel: 0.0.6 - underscore: 1.13.8 - typedarray@0.0.6: {} typescript@5.9.3: {} - uc.micro@2.1.0: {} - uglify-js@3.19.3: optional: true - underscore@1.13.8: {} - undici-types@5.26.5: {} undici-types@6.21.0: {} @@ -14714,10 +13519,6 @@ snapshots: undici@7.28.0: {} - unicorn-magic@0.1.0: {} - - unicorn-magic@0.3.0: {} - unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -14759,8 +13560,6 @@ snapshots: universal-user-agent@7.0.3: {} - universalify@2.0.1: {} - unpipe@1.0.0: {} unplugin@2.3.11: @@ -14778,8 +13577,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - url-join@4.0.1: {} - use-callback-ref@1.3.3(@types/react@19.2.13)(react@19.2.4): dependencies: react: 19.2.4 @@ -14805,15 +13602,8 @@ snapshots: uuid@11.1.1: {} - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - vary@1.1.2: {} - version-range@4.15.0: {} - vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3 @@ -14904,14 +13694,8 @@ snapshots: webpack-virtual-modules@0.6.2: {} - whatwg-encoding@3.1.1: - dependencies: - iconv-lite: 0.6.3 - whatwg-mimetype@3.0.0: {} - whatwg-mimetype@4.0.0: {} - whatwg-url@14.2.0: dependencies: tr46: 5.1.1 @@ -14958,23 +13742,12 @@ snapshots: xml-naming@0.1.0: {} - xml2js@0.5.0: - dependencies: - sax: 1.6.0 - xmlbuilder: 11.0.1 - - xmlbuilder@11.0.1: {} - - xstate@5.28.0: {} - xtend@4.0.2: {} y18n@5.0.8: {} yallist@3.1.1: {} - yallist@4.0.0: {} - yallist@5.0.0: {} yaml@2.8.3: {} @@ -14991,19 +13764,10 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yauzl@3.4.0: dependencies: pend: 1.2.0 - yazl@2.5.1: - dependencies: - buffer-crc32: 0.2.13 - yazl@3.3.1: dependencies: buffer-crc32: 1.0.0 diff --git a/scripts/dev-compose.sh b/scripts/dev-compose.sh index 70aa714b6..cbaeaf8e6 100755 --- a/scripts/dev-compose.sh +++ b/scripts/dev-compose.sh @@ -3,7 +3,7 @@ # dev-compose.sh — Docker Compose wrapper with shared-infra auto-detection # ============================================================================= # Replaces direct `docker compose` calls in pnpm scripts. Automatically: -# - Loads .env.local for compose variable interpolation (if it exists) +# - Loads generated .env, then( overlays .env.local if it exists) # - Adds --profile mongodb (starts local MongoDB) UNLESS SCOPE_SHARED_INFRA=1 # - Strips 'mongodb' from service arguments when using shared infra # @@ -50,9 +50,11 @@ for arg in "$@"; do fi done -# Always pass .env.local for compose variable interpolation (if it exists) +# Passing any --env-file disables Compose's implicit .env loading. Include the +# generated worktree file explicitly before .env.local so local overrides do not +# discard COMPOSE_PROJECT_NAME or the worktree's offset ports. if [ -f .env.local ]; then - EXTRA_ARGS+=(--env-file .env.local) + EXTRA_ARGS+=(--env-file .env --env-file .env.local) fi # Handle mongodb: add profile OR strip from service args diff --git a/scripts/dev-entrypoint.sh b/scripts/dev-entrypoint.sh index a00bf6db7..1b65487ef 100755 --- a/scripts/dev-entrypoint.sh +++ b/scripts/dev-entrypoint.sh @@ -12,6 +12,15 @@ if [ -z "$SERVICE_DIR" ]; then exit 1 fi +INSPECT_ARGS=() +if [ -n "${NODE_INSPECT_PORT:-}" ]; then + if ! [[ "$NODE_INSPECT_PORT" =~ ^[0-9]+$ ]] || (( NODE_INSPECT_PORT < 1 || NODE_INSPECT_PORT > 65535 )); then + echo "ERROR: NODE_INSPECT_PORT must be an integer between 1 and 65535" + exit 1 + fi + INSPECT_ARGS=("--inspect=0.0.0.0:${NODE_INSPECT_PORT}") +fi + # Sentinel file written by cancelExit() when a run is cancelled. # When detected, we kill tsx watch and exit — stopping the container. CANCEL_SENTINEL="/tmp/.scope-cancel-exit" @@ -26,7 +35,7 @@ cd /app/apps/$SERVICE_DIR # Exclude shared dist — tsc --watch (above) already recompiles it and tsx # re-resolves modules on import. Without this, every shared rebuild triggers # a tsx restart that can overlap with in-flight message processing. -npx tsx watch --exclude '/app/packages/shared/dist/**' src/index.ts & +npx tsx watch --exclude '/app/packages/shared/dist/**' "${INSPECT_ARGS[@]}" src/index.ts & TSX_PID=$! # Forward SIGTERM/SIGINT to children so docker stop works gracefully diff --git a/scripts/ensure-dev-certs.sh b/scripts/ensure-dev-certs.sh index e0b9a0128..7cc567fc4 100755 --- a/scripts/ensure-dev-certs.sh +++ b/scripts/ensure-dev-certs.sh @@ -5,9 +5,8 @@ # MSAL requires an https:// authority (no localhost exemption), so the local # Entra emulator must serve HTTPS with a certificate the browser already trusts. # This uses mkcert to install a local CA into the OS/browser trust store (once) -# and mint a `localhost` leaf cert. Both steps are idempotent, so this is safe to -# run on every `pnpm docker:dev:*` bring-up — after the first run it is a no-op -# and does not re-prompt. +# and mint a leaf cert for both `localhost` and the Compose host `entra-local`. +# Existing localhost-only, expired, or differently signed certs are renewed. # # Invoked automatically by scripts/dev-compose.sh when the `auth` profile is # active. The host key stays `0600` (owner-only); the entra-local-certs-init @@ -24,6 +23,7 @@ done CERT_DIR="${DEV_CERT_DIR:-.certs}" CERT_FILE="$CERT_DIR/entra-local.pem" KEY_FILE="$CERT_DIR/entra-local-key.pem" +CA_FILE="$CERT_DIR/rootCA.pem" if ! command -v mkcert >/dev/null 2>&1; then cat >&2 <<'EOF' @@ -41,17 +41,29 @@ EOF exit 1 fi +if ! command -v openssl >/dev/null 2>&1; then + echo "[ensure-dev-certs] openssl is required to validate local auth certificates. Install it and re-run your command." >&2 + exit 1 +fi + # Install the local CA into the system/browser trust store. Idempotent: mkcert # skips (and does not prompt) when the CA is already trusted. mkcert -install >/dev/null 2>&1 || mkcert -install mkdir -p "$CERT_DIR" +cp "$(mkcert -CAROOT)/rootCA.pem" "$CA_FILE" +chmod 0644 "$CA_FILE" -if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then +if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]] && + openssl x509 -in "$CERT_FILE" -noout -checkend 86400 >/dev/null 2>&1 && + openssl verify -CAfile "$CA_FILE" -verify_hostname localhost "$CERT_FILE" >/dev/null 2>&1 && + openssl verify -CAfile "$CA_FILE" -verify_hostname entra-local "$CERT_FILE" >/dev/null 2>&1; then + chmod 0600 "$KEY_FILE" echo "[ensure-dev-certs] cert already present at $CERT_FILE" exit 0 fi -echo "[ensure-dev-certs] minting localhost cert -> $CERT_FILE" -mkcert -cert-file "$CERT_FILE" -key-file "$KEY_FILE" localhost 127.0.0.1 ::1 >/dev/null -echo "[ensure-dev-certs] done. Browser trusts https://localhost (via mkcert local CA)." +echo "[ensure-dev-certs] minting localhost / entra-local cert -> $CERT_FILE" +mkcert -cert-file "$CERT_FILE" -key-file "$KEY_FILE" localhost 127.0.0.1 ::1 entra-local >/dev/null +chmod 0600 "$KEY_FILE" +echo "[ensure-dev-certs] done. Browser and Compose clients can trust the mkcert CA at $CA_FILE." diff --git a/scripts/ensure-dev-certs.test.ts b/scripts/ensure-dev-certs.test.ts new file mode 100644 index 000000000..508e4ad20 --- /dev/null +++ b/scripts/ensure-dev-certs.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const script = resolve("scripts/ensure-dev-certs.sh"); +const directories: string[] = []; + +function fixture(existing = false) { + const directory = mkdtempSync(join(tmpdir(), "scope-dev-certs-")); + directories.push(directory); + const bin = join(directory, "bin"); + const caRoot = join(directory, "ca-root"); + const certDir = join(directory, "certs"); + for (const path of [bin, caRoot, certDir]) mkdirSync(path); + writeFileSync(join(caRoot, "rootCA.pem"), "public CA"); + writeFileSync(join(caRoot, "rootCA-key.pem"), "private CA key"); + const cert = join(certDir, "entra-local.pem"); + const key = join(certDir, "entra-local-key.pem"); + if (existing) { + writeFileSync(cert, "existing certificate"); + writeFileSync(key, "existing key"); + } + const calls = join(directory, "calls"); + writeFileSync(calls, ""); + const mkcert = join(bin, "mkcert"); + writeFileSync(mkcert, `#!/bin/sh +set -eu +printf '%s\\n' "$*" >> "$CALLS" +case "$1" in + -install) exit 0 ;; + -CAROOT) printf '%s\\n' "$CAROOT" ;; + -cert-file) + if [ "\${MINT_FAIL:-0}" = "1" ]; then exit 1; fi + printf 'new certificate' > "$2" + printf 'new key' > "$4" + ;; + *) exit 1 ;; +esac +`); + chmodSync(mkcert, 0o755); + const openssl = join(bin, "openssl"); + writeFileSync(openssl, `#!/bin/sh +set -eu +printf '%s\\n' "$*" >> "$CALLS" +case "$*" in + *-checkend*) exit "\${EXPIRY_FAIL:-0}" ;; + *-verify_hostname\\ entra-local*) exit "\${HOSTNAME_FAIL:-0}" ;; + *) exit "\${VERIFY_FAIL:-0}" ;; +esac +`); + chmodSync(openssl, 0o755); + const env = { + ...process.env, + PATH: `${bin}:/usr/local/bin:/opt/homebrew/bin:${process.env.PATH ?? ""}`, + CAROOT: caRoot, + DEV_CERT_DIR: certDir, + CALLS: calls, + }; + return { + cert, + key, + certDir, + caRoot, + calls: () => readFileSync(calls, "utf8"), + run: (overrides: NodeJS.ProcessEnv = {}) => + spawnSync("bash", [script], { + encoding: "utf8", + env: { ...env, ...overrides }, + }), + }; +} + +afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("ensure-dev-certs.sh", () => { + it("mints browser and Compose hostnames and exports only the public CA", () => { + const test = fixture(); + const result = test.run(); + expect(result.status, result.stderr).toBe(0); + expect(test.calls()).toContain("localhost 127.0.0.1 ::1 entra-local"); + expect(readFileSync(join(test.certDir, "rootCA.pem"), "utf8")).toBe("public CA"); + expect(existsSync(join(test.certDir, "rootCA-key.pem"))).toBe(false); + expect(statSync(test.key).mode & 0o777).toBe(0o600); + expect(statSync(join(test.certDir, "rootCA.pem")).mode & 0o777).toBe(0o644); + }); + + it("reuses valid certificates while restoring the exported CA and key permissions", () => { + const test = fixture(true); + const result = test.run(); + expect(result.status, result.stderr).toBe(0); + expect(test.calls()).not.toContain("-cert-file"); + expect(test.calls()).toContain("-verify_hostname localhost"); + expect(test.calls()).toContain("-verify_hostname entra-local"); + expect(readFileSync(test.cert, "utf8")).toBe("existing certificate"); + expect(readFileSync(join(test.certDir, "rootCA.pem"), "utf8")).toBe("public CA"); + expect(statSync(test.key).mode & 0o777).toBe(0o600); + }); + + it.each([ + ["localhost-only certificate", { HOSTNAME_FAIL: "1" }], + ["expiring certificate", { EXPIRY_FAIL: "1" }], + ["untrusted certificate after CA rotation", { VERIFY_FAIL: "1" }], + ])("renews an existing %s", (_reason, overrides) => { + const test = fixture(true); + const result = test.run(overrides); + expect(result.status, result.stderr).toBe(0); + expect(test.calls()).toContain("-cert-file"); + expect(readFileSync(test.cert, "utf8")).toBe("new certificate"); + }); + + it("renews when the private key is missing", () => { + const test = fixture(true); + rmSync(test.key); + const result = test.run(); + expect(result.status, result.stderr).toBe(0); + expect(test.calls()).toContain("-cert-file"); + expect(existsSync(test.key)).toBe(true); + }); + + it("fails rather than reporting success when certificate generation fails", () => { + const test = fixture(); + const result = test.run({ MINT_FAIL: "1" }); + expect(result.status).toBe(1); + expect(result.stdout).not.toContain("[ensure-dev-certs] done."); + }); + + it("fails if the public CA cannot be exported", () => { + const test = fixture(); + rmSync(join(test.caRoot, "rootCA.pem")); + const result = test.run(); + expect(result.status).not.toBe(0); + expect(test.calls()).not.toContain("-cert-file"); + expect(result.stderr).toContain("rootCA.pem"); + }); +}); diff --git a/website/src/openapi/scope-openapi.json b/website/src/openapi/scope-openapi.json index 87bd4cf11..025338fd1 100644 --- a/website/src/openapi/scope-openapi.json +++ b/website/src/openapi/scope-openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Scope API","version":"1.0.0","description":"REST API for the Scope platform — benchmarking AI coding agents"},"servers":[{"url":"/","description":"Current server"}],"components":{"schemas":{"KeyResponse":{"type":"object","properties":{},"additionalProperties":{}},"KeyInput":{"type":"object","properties":{},"additionalProperties":{}},"ValidateKeyInput":{"type":"object","properties":{"token":{"type":"string"}},"required":["token"]},"AccountResponse":{"type":"object","properties":{},"additionalProperties":{}},"AccountInput":{"type":"object","properties":{},"additionalProperties":{}},"ProjectResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","createdAt"]},"CreateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"creator":{"type":"string"}},"required":["name"]},"UpdateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"RequestResponse":{"type":"object","properties":{"_id":{"type":"string"},"scenario":{"$ref":"#/components/schemas/Scenario"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"maxIterations":{"type":"number"},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"deletedAt":{"type":["string","null"],"format":"date-time"},"taskPromptId":{"type":"string"},"agentsMdPromptId":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"profileId":{"type":"string"},"profileVersionId":{"type":"string"},"submissionId":{"type":"string"},"priority":{"type":"integer","default":0},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}},"gateSummaries":{"type":"array","items":{"$ref":"#/components/schemas/GateRunSummary"}},"run":{"$ref":"#/components/schemas/RunState"},"projectId":{"type":"string"}},"required":["_id","scenario","workerType","createdAt","projectId"]},"Scenario":{"type":"object","properties":{"version":{"type":"string","enum":["v1","v2"]},"task":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}}},"required":["task","criteria"]},"Persona":{"type":"object","properties":{"personality":{"type":"string","enum":["demanding","friendly"]},"experience":{"type":"string","enum":["junior","senior"]},"verbosity":{"type":"string","enum":["brief","moderate"]},"type":{"type":"string","enum":["traditional","ai_assisted","vibe"]}},"required":["personality","experience","verbosity","type"]},"GateConfig":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"promptId":{"type":"string"},"promptText":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}},"maxIterations":{"type":"integer","minimum":1,"maximum":50}},"required":["gate","criteria"]},"GateRunSummary":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"status":{"type":"string","enum":["passed","failed","skipped"]},"iterations":{"type":"integer","minimum":0}},"required":["gate","status","iterations"]},"RunState":{"type":"object","properties":{"_id":{"type":"string"},"attemptNumber":{"type":"integer","minimum":1},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"queuedQueueName":{"type":"string"},"outcome":{"type":"string","enum":["succeeded","failed","finished"]},"result":{"type":"string"},"error":{"type":"string"},"logsUrl":{"type":"string"},"updatedAt":{"type":["string","null"],"format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"turns":{"type":"array","items":{"$ref":"#/components/schemas/ConversationTurn"}},"workerVersion":{"type":"string"},"os":{"type":"object","properties":{"platform":{"type":"string"},"release":{"type":"string"},"arch":{"type":"string"}},"required":["platform","release","arch"]},"lastHeartbeatAt":{"type":["string","null"],"format":"date-time"},"worker":{"type":"object","properties":{"instanceId":{"type":"string"},"podName":{"type":"string"}},"required":["instanceId"]},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"setupVideoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"aiCallCount":{"type":"number"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"pausedAt":{"type":["string","null"],"format":"date-time"},"resumedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","attemptNumber","status"]},"ConversationTurn":{"type":"object","properties":{"iteration":{"type":"number"},"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"codingAgentResponse":{"type":"string"},"judgeFeedback":{"type":"string"},"snapshotUrl":{"type":"string"},"passed":{"type":"boolean"},"timestamp":{"type":["string","null"],"format":"date-time"},"criteriaResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"startedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"toolCalls":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"arguments":{"type":"object","additionalProperties":{}},"response":{"type":"string"},"timestamp":{"type":"string"}},"required":["id","name","arguments"]}},"toolCallsUrl":{"type":"string"},"toolCallCount":{"type":"number"},"aiCallCount":{"type":"number"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"chatResultUrl":{"type":"string"},"chatResultFormat":{"type":"string"}},"required":["iteration","judgeFeedback","snapshotUrl","passed","timestamp"]},"CriterionResult":{"type":"object","properties":{"criterionId":{"type":"string"},"passed":{"type":"boolean"},"feedback":{"type":"string"},"evaluated":{"type":"boolean"}},"required":["criterionId","passed","feedback","evaluated"]},"TokenUsage":{"type":"object","properties":{"promptTokens":{"type":"number"},"completionTokens":{"type":"number"},"totalTokens":{"type":"number"}},"required":["promptTokens","completionTokens","totalTokens"]},"CreateRequestInput":{"type":"object","properties":{"scenario":{"$ref":"#/components/schemas/Scenario"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"maxIterations":{"type":"integer","minimum":1,"maximum":50},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"profileId":{"type":"string"},"profileVariations":{"type":"array","items":{"type":"string"}},"priority":{"type":"integer"},"agentsMd":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}}},"required":["scenario"]},"RunFacetsResponse":{"type":"object","properties":{"total":{"type":"number"},"facets":{"type":"object","properties":{"workerType":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"status":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"outcome":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"model":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"os":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"priority":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"agentVersion":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"profileId":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}}},"required":["workerType","status","outcome","model","os","priority","agentVersion","profileId"]}},"required":["total","facets"]},"RunFacetBucket":{"type":"object","properties":{"value":{"type":"string"},"count":{"type":"number"}},"required":["value","count"]},"PaginatedRunsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"Cursors":{"type":"object","properties":{"next":{"type":["string","null"]},"prev":{"type":["string","null"]}},"required":["next","prev"]},"PaginatedRunGroupsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RunGroup"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"RunGroup":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"runIds":{"type":"array","items":{"type":"string"}},"aggregates":{"$ref":"#/components/schemas/GroupAggregates"},"uniform":{"$ref":"#/components/schemas/GroupUniformValues"}},"required":["key","label","runIds","aggregates","uniform"]},"GroupAggregates":{"type":"object","properties":{"count":{"type":"number"},"turns":{"$ref":"#/components/schemas/AggregateStats"},"duration":{"$ref":"#/components/schemas/AggregateStats"},"promptTokens":{"$ref":"#/components/schemas/AggregateStats"},"completionTokens":{"$ref":"#/components/schemas/AggregateStats"},"statusCounts":{"type":"object","additionalProperties":{"type":"number"}},"outcomeCounts":{"type":"object","additionalProperties":{"type":"number"}}},"required":["count","turns","duration","promptTokens","completionTokens","statusCounts","outcomeCounts"]},"AggregateStats":{"type":["object","null"],"properties":{"min":{"type":"number"},"max":{"type":"number"},"mean":{"type":"number"},"stdDev":{"type":"number"}},"required":["min","max","mean","stdDev"]},"GroupUniformValues":{"type":"object","properties":{"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":"string"},"platform":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"submissionId":{"type":"string"},"task":{"type":"string"}}},"BulkResubmitInput":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"count":{"type":"integer","minimum":1,"maximum":10,"default":1},"overrides":{"type":"object","properties":{"profileId":{"type":["string","null"]},"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":["string","null"]},"reasoningEffort":{"type":["string","null"]},"maxIterations":{"type":["number","null"]},"mcpServers":{"type":["array","null"],"items":{"type":"string"}},"skillRevisions":{"type":["array","null"],"items":{"type":"string"}},"extensions":{"type":["array","null"],"items":{"type":"string"}}}}},"required":["ids"]},"ReportResponse":{"type":"object","properties":{"_id":{"type":"string"},"requestId":{"type":"string"},"templateId":{"type":"string"},"reporter":{"$ref":"#/components/schemas/Reporter"},"content":{"type":"string"},"status":{"type":"string","enum":["pending","generating","completed","failed"]},"error":{"type":"string"},"insightReferences":{"type":"array","items":{"$ref":"#/components/schemas/InsightReference"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","requestId","status","createdAt","projectId"]},"Reporter":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"gitHash":{"type":"string"},"model":{"type":"string"},"agentId":{"type":"string"},"agentVersion":{"type":"string"}},"required":["id","name","gitHash","model","agentId","agentVersion"]},"InsightReference":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]},"CreateCriteriaInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}}},"required":["id","prompt"]},"CriteriaResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"CriteriaGraph":{"type":"object","properties":{"nodes":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphNode"}},"edges":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphEdge"}}},"required":["nodes","edges"]},"CriteriaGraphNode":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}}},"required":["id","prompt"]},"CriteriaGraphEdge":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"]},"UpdateCriteriaInput":{"type":"object","properties":{"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}}}},"CreatePromptFeatureInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["id","prompt"]},"PromptType":{"type":"string","enum":["select","build","test","run","deploy","agents.md"]},"PromptFeatureResult":{"type":"object","properties":{"featureId":{"type":"string"},"detected":{"type":"boolean"},"evaluated":{"type":"boolean"}},"required":["featureId","detected","evaluated"]},"SuggestedPromptFeature":{"type":"object","properties":{"suggestedId":{"type":"string"},"behavior":{"type":"string"},"prompt":{"type":"string"}},"required":["suggestedId","behavior","prompt"]},"PromptFeatureResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"UpdatePromptFeatureInput":{"type":"object","properties":{"prompt":{"type":"string"}}},"TaskPromptResponse":{"type":"object","properties":{"_id":{"type":"string"},"keyId":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"text":{"type":"string"},"contentBlobUrl":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","keyId","createdAt","projectId"]},"CreateTaskPromptInput":{"type":"object","properties":{"text":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["text"]},"PatchTaskPromptFeatureInput":{"type":"object","properties":{"detected":{"type":"boolean"}},"required":["detected"]},"CreateReportInput":{"type":"object","properties":{"requestId":{"type":"string"},"templateId":{"type":"string"}},"required":["requestId"]},"BulkCreateReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}},"templateId":{"type":"string"}},"required":["requestIds"]},"BulkReportStatusInput":{"type":"object","properties":{"reportIds":{"type":"array","items":{"type":"string"}}},"required":["reportIds"]},"BulkReportSummaryResponse":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/ReportSummary"}},"ReportSummary":{"type":"object","properties":{"total":{"type":"number"},"pending":{"type":"number"},"generating":{"type":"number"},"completed":{"type":"number"},"failed":{"type":"number"}},"required":["total","pending","generating","completed","failed"]},"BulkReportSummaryInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"TriggerReportsInput":{"type":"object","properties":{"requestId":{"type":"string"}},"required":["requestId"]},"BulkTriggerReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"InsightResponse":{"type":"object","properties":{"_id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"upvotes":{"type":"number"},"downvotes":{"type":"number"},"blocked":{"type":"boolean"},"referenceCount":{"type":"number"},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","title","description","upvotes","downvotes","blocked","referenceCount","createdBy","createdAt","projectId"]},"ProfileWithVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"version":{"$ref":"#/components/schemas/ProfileVersionResponse"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","version","projectId"]},"ProfileVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"profileId":{"type":"string"},"version":{"type":"number"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","profileId","version","workerType","model","createdAt","projectId"]},"CreateProfileInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["name","workerType","model"]},"ProfileResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","projectId"]},"UpdateProfileIdentity":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512}}},"ReportTemplateResponse":{"type":"object","properties":{"_id":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","id","name","userPrompt","createdAt","projectId"]},"ReportTemplateSystemPrompt":{"type":"object","properties":{"mode":{"type":"string","enum":["append","override"]},"content":{"type":"string"}},"required":["mode","content"]},"ReportTrigger":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["always"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["criteria"]},"criteriaIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","criteriaIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["taskPrompt"]},"taskPromptIds":{"type":"array","items":{"type":"string"}}},"required":["type","taskPromptIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["promptFeature"]},"featureIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","featureIds"]}]},"CreateReportTemplateInput":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0}},"required":["id","name","userPrompt"]},"UpdateReportTemplateInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"allOf":[{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},{"type":["object","null"]}]},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":["string","null"]},"timeoutMs":{"type":["integer","null"],"exclusiveMinimum":0}}},"AgentResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"},"versions":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","supportedModels","createdAt"]},"AgentCapabilities":{"type":"object","properties":{"supportsReasoningEffort":{"type":"boolean"},"supportsMcpServers":{"type":"boolean"},"supportsSkills":{"type":"boolean"},"supportsExtensions":{"type":"boolean"}}},"AgentVersion":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string"},"status":{"type":"string","enum":["active","retired"]},"createdAt":{"type":["string","null"],"format":"date-time"}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","status","createdAt"]},"CreateAgentInput":{"type":"object","properties":{"_id":{"type":"string","minLength":1},"name":{"type":"string","minLength":1},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}},"required":["_id","name"]},"UpdateAgentInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}}},"RegisterAgentVersionInput":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string","minLength":1}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","queueName"]},"PatchAgentVersionInput":{"type":"object","properties":{"status":{"type":"string","enum":["active","retired"]}},"required":["status"]},"ModelResponse":{"type":"object","properties":{"_id":{"type":"string"},"modelId":{"type":"string"},"provider":{"type":"string"},"agentId":{"type":"string"},"firstSeenAt":{"type":["string","null"],"format":"date-time"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"disappearedAt":{"type":["string","null"],"format":"date-time"},"providerAvailableFrom":{"type":["string","null"],"format":"date-time"},"providerEndOfLife":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"$ref":"#/components/schemas/ModelCapabilities"}},"required":["_id","modelId","provider","agentId","firstSeenAt","lastSeenAt"]},"ModelCapabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}},"McpServerResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","type","createdAt","projectId"]},"McpServerHeader":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]},"UpdateMcpServerInput":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}}},"SkillResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","source","skillName","name","origin","createdAt","projectId"]},"SkillSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"source":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"installs":{"type":"number"}},"required":["id","name","source","internal"]},"SkillDiscoveryResult":{"type":"object","properties":{"skillName":{"type":"string"},"skillPath":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"existsInLibrary":{"type":"boolean"},"currentRevisionCommitSha":{"type":"string"},"latestUpstreamCommitSha":{"type":"string"},"updateAvailable":{"type":"boolean"},"lastImportedAt":{"type":"string"}},"required":["skillName","skillPath"]},"SkillRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"ref":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"skillPath":{"type":"string"},"commitHash":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"name":{"type":"string"},"description":{"type":"string"},"license":{"type":"string"},"compatibility":{"type":"string"},"allowedTools":{"type":"string"},"metadata":{"type":"object","additionalProperties":{"type":"string"}},"content":{"type":"string"},"archiveUrl":{"type":"string"},"validationWarnings":{"type":"array","items":{"type":"string"}},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","ref","source","skillName","skillPath","commitHash","commitTimestamp","name","description","content","archiveUrl","resolvedAt","createdAt","projectId"]},"CreateSkillInput":{"type":"object","properties":{"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]}},"required":["source","skillName","name","origin"]},"CodebaseResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"defaultBranch":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","sourceType","revisionCounter","createdAt","projectId"]},"UpdateCodebaseInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"defaultBranch":{"type":"string"}}},"CodebaseRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"codebaseId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"requestedRef":{"type":"string"},"resolvedCommitSha":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"originalFilename":{"type":"string"},"contentSha256":{"type":"string"},"archiveUrl":{"type":"string"},"sizeBytes":{"type":"number"},"fileCount":{"type":"number"},"creator":{"type":"string"},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","codebaseId","slug","revisionNumber","ref","sourceType","archiveUrl","resolvedAt","createdAt","projectId"]},"ResolveCodebaseRevisionInput":{"type":"object","properties":{"requestedRef":{"type":"string"},"creator":{"type":"string"}}},"ExtensionResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","publisher","name","origin","createdAt","projectId"]},"ExtensionSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"publisher":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"version":{"type":"string"}},"required":["id","name","publisher","internal"]},"CreateExtensionInput":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]}},"required":["_id","publisher","name","origin"]},"UpdateExtensionInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"ExtensionVersionInfo":{"type":"object","properties":{"version":{"type":"string"},"preRelease":{"type":"boolean"},"lastUpdated":{"type":"string"}},"required":["version","preRelease","lastUpdated"]},"CreateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"}},"required":["title","description"]},"UpdateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}}}},"FeatureFlagResponse":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"enabled":{"type":"boolean"},"updatedAt":{"type":["string","null"],"format":"date-time"}},"required":["key","label","enabled","updatedAt"]},"UpdateFeatureFlagInput":{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]}},"parameters":{}},"paths":{"/api/v1/keys/preview":{"post":{"tags":["Keys"],"summary":"Preview key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}}},"/api/v1/keys":{"post":{"tags":["Keys"],"summary":"Create key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"201":{"description":"Key created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"get":{"tags":["Keys"],"summary":"List keys","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/KeyResponse"}}}}}}}},"/api/v1/keys/{id}":{"get":{"tags":["Keys"],"summary":"Get key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"put":{"tags":["Keys"],"summary":"Update key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"delete":{"tags":["Keys"],"summary":"Delete key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Key deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/api/v1/keys/{id}/validate":{"post":{"tags":["Keys"],"summary":"Validate key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateKeyInput"}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Key validation result"}}}}}}},"/api/v1/accounts":{"post":{"tags":["Accounts"],"summary":"Create account","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"201":{"description":"Account created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"get":{"tags":["Accounts"],"summary":"List accounts","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AccountResponse"}}}}}}}},"/api/v1/accounts/{id}":{"get":{"tags":["Accounts"],"summary":"Get account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"put":{"tags":["Accounts"],"summary":"Update account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"200":{"description":"Account updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"delete":{"tags":["Accounts"],"summary":"Delete account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Account deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/health":{"get":{"tags":["Health"],"summary":"Liveness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"version":{"type":"string"}},"required":["status","version"]}}}}}}},"/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"migrations":{}},"required":["status"]}}}},"503":{"description":"Service is not ready"}}}},"/about":{"get":{"tags":["System"],"summary":"API metadata","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"description":{"type":"string"},"workers":{"type":"array","items":{"type":"string"}}},"required":["name","version","buildTime","environment","description","workers"]}}}}}}},"/api/v1/version":{"get":{"tags":["System"],"summary":"Version info","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"commit":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"strictAgentCapabilities":{"type":"boolean"}},"required":["commit","buildTime","environment","strictAgentCapabilities"]}}}}}}},"/api/v1/projects":{"get":{"tags":["Projects"],"summary":"List all projects","parameters":[{"schema":{"type":"string","enum":["true","false"],"description":"When true, include soft-deleted projects in the result"},"required":false,"description":"When true, include soft-deleted projects in the result","name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}}}}}},"post":{"tags":["Projects"],"summary":"Create a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/projects/{id}":{"get":{"tags":["Projects"],"summary":"Get a project","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"patch":{"tags":["Projects"],"summary":"Update a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"delete":{"tags":["Projects"],"summary":"Soft-delete a project","responses":{"204":{"description":"Success"},"404":{"description":"Project not found"}}}},"/api/v1/projects/{id}/restore":{"post":{"tags":["Projects"],"summary":"Restore a soft-deleted project","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}}},"/api/v1/requests":{"post":{"tags":["Requests"],"summary":"Submit request(s)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/CreateRequestInput"},{"type":"object","properties":{"count":{"type":"number","minimum":1,"maximum":10,"default":1},"skills":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"codebase":{"type":"string"}}}]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/RequestResponse"},{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}]}}}}}},"get":{"tags":["Requests"],"summary":"List requests","parameters":[{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"profileId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"status","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"outcome","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"model","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"os","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"priority","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"agentVersion","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"type":"string","enum":["task","submissionId","profile"]},"required":false,"name":"groupBy","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"after","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"last","in":"query"},{"schema":{"type":"string","enum":["created","updated","priority","worker","status","id","duration","createdAt"]},"required":false,"name":"sortBy","in":"query"},{"schema":{"type":"string","enum":["asc","desc"]},"required":false,"name":"sortDir","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"turns","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"turnsOp","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"maxIterations","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"maxIterationsOp","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaginatedRunsResponse"},{"$ref":"#/components/schemas/PaginatedRunGroupsResponse"}]}}}}}}},"/api/v1/requests/facets":{"get":{"tags":["Requests"],"summary":"List run filter facets","description":"Returns every distinct value and its full-dataset count per categorical filter dimension for the Runs list rail. Counts are absolute over all non-deleted runs **in the given project**: they intentionally ignore the active search, date range, iteration, and categorical selections so every selectable value stays visible with a stable count. Requires ?projectId=. Computed with parallel $group aggregations (Cosmos has no $facet) and cached per-project for a short TTL.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunFacetsResponse"}}}}}}},"/api/v1/requests/{id}":{"get":{"tags":["Requests"],"summary":"Get request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestResponse"}}}},"404":{"description":"Not found"}}},"delete":{"tags":["Requests"],"summary":"Soft-delete request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Not found"}}}},"/api/v1/analysis":{"get":{"tags":["Requests"],"summary":"Compute pass@k / success@T metrics","description":"Aggregates pass@k / success@T metrics over a single project's done runs. Requires ?projectId=.","parameters":[{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"k","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Analysis metrics"}}}}}}},"/api/v1/requests/bulk-resubmit":{"post":{"tags":["Requests"],"summary":"Bulk resubmit requests","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResubmitInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}}}}}}},"/api/v1/requests/bulk":{"delete":{"tags":["Requests"],"summary":"Bulk soft-delete requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"number"}},"required":["deleted"]}}}}}}},"/api/v1/requests/archive":{"post":{"tags":["Requests"],"summary":"Download batch archive of multiple runs","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Gzipped batch archive containing individual run archives"},"400":{"description":"Invalid input"},"404":{"description":"One or more runs not found"}}}},"/api/v1/runs/upload":{"post":{"tags":["Requests"],"summary":"Import run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"}}}},"/api/v1/runs/upload-batch":{"post":{"tags":["Requests"],"summary":"Import batch run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"207":{"description":"Multi-status: 201 if all runs imported, 400 if none imported, 207 if partial"},"400":{"description":"No archive uploaded, empty archive, or all runs failed"}}}},"/api/v1/requests/{id}/reports":{"get":{"tags":["Reports"],"summary":"Get reports for request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Run not found"}}}},"/api/v1/requests/{id}/runs":{"get":{"tags":["Requests"],"summary":"List attempts for a request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RunState"}}}}},"404":{"description":"Request not found"}}}},"/api/v1/requests/{id}/runs/{runId}":{"get":{"tags":["Requests"],"summary":"Get a single attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunState"}}}},"404":{"description":"Request or run not found"}}}},"/api/v1/requests/bulk-retry":{"post":{"tags":["Requests"],"summary":"Bulk retry requests (start new attempts)","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"force":{"type":"boolean"}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"retried":{"type":"integer"},"skipped":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"},"error":{"type":"string"}},"required":["requestId"]}}},"required":["retried","skipped","results"]}}}}}}},"/api/v1/requests/{id}/retry":{"post":{"tags":["Requests"],"summary":"Retry a request (start a new attempt)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"force":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"}},"required":["requestId","runId","attemptNumber"]}}}},"404":{"description":"Request not found"},"409":{"description":"Conflict — request is not in a retryable state, or a concurrent retry won the race"},"422":{"description":"Cannot retry — current run not yet terminal"}}}},"/api/v1/requests/{id}/priority":{"post":{"tags":["Requests"],"summary":"Set priority on a single request","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"priority":{"type":"integer"}},"required":["priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"priority":{"type":"number"}},"required":["id","priority"]}}}}}}},"/api/v1/requests/bulk-priority":{"post":{"tags":["Requests"],"summary":"Set priority on multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100},"priority":{"type":"integer"}},"required":["ids","priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/pause":{"post":{"tags":["Requests"],"summary":"Pause a single request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/{id}/resume":{"post":{"tags":["Requests"],"summary":"Resume a paused request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/bulk-pause":{"post":{"tags":["Requests"],"summary":"Pause multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/bulk-resume":{"post":{"tags":["Requests"],"summary":"Resume multiple paused requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/cancel":{"post":{"tags":["Requests"],"summary":"Cancel a request (marks as done/failed and signals worker to exit)","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"previousStatus":{"type":"string"},"status":{"type":"string"},"outcome":{"type":"string"}},"required":["id","previousStatus","status","outcome"]}}}}}}},"/api/v1/requests/bulk-cancel":{"post":{"tags":["Requests"],"summary":"Cancel multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"cancelled":{"type":"number"},"skipped":{"type":"number"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"cancelled":{"type":"boolean"},"previousStatus":{"type":"string"},"error":{"type":"string"}},"required":["id","cancelled"]}}},"required":["cancelled","skipped","results"]}}}}}}},"/api/v1/requests/{id}/logs":{"get":{"tags":["Requests"],"summary":"Stream request logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/logs":{"get":{"tags":["Requests"],"summary":"Stream logs for a specific attempt (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific attempt and iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/video":{"get":{"tags":["Requests"],"summary":"Download session recording","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/video":{"get":{"tags":["Requests"],"summary":"Download session recording for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/criteria/generate-prompt":{"post":{"tags":["Criteria"],"summary":"Generate criterion prompt from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"},"gates":{"type":"array","items":{"type":"string"}}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"},"suggestedId":{"type":"string"},"suggestedParents":{"type":"array","items":{"type":"string"}},"suggestedChildren":{"type":"array","items":{"type":"string"}}},"required":["prompt","suggestedId","suggestedParents","suggestedChildren"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/criteria/seed":{"post":{"tags":["Criteria"],"summary":"Seed criteria in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"criteria":{"type":"array","items":{"$ref":"#/components/schemas/CreateCriteriaInput"}}},"required":["criteria"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}},"400":{"description":"Seed batch would introduce a dependency cycle"}}}},"/api/v1/criteria":{"get":{"tags":["Criteria"],"summary":"List criteria","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","description":"Comma-separated criterion IDs to include"},"required":false,"description":"Comma-separated criterion IDs to include","name":"ids","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"When true and ids is set, also include dependency ancestors"},"required":false,"description":"When true and ids is set, also include dependency ancestors","name":"ancestors","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaResponse"}}}}}}},"post":{"tags":["Criteria"],"summary":"Create criterion","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCriteriaInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"409":{"description":"Criterion already exists"}}}},"/api/v1/criteria/mdp":{"get":{"tags":["Criteria"],"summary":"Compute MDP transitions","parameters":[{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"since","in":"query"},{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{}}}}}}}},"/api/v1/criteria/graph":{"get":{"tags":["Criteria"],"summary":"Get criteria DAG","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaGraph"}}}}}}},"/api/v1/criteria/{id}":{"get":{"tags":["Criteria"],"summary":"Get criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"404":{"description":"Criterion not found"}}},"put":{"tags":["Criteria"],"summary":"Update criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCriteriaInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"400":{"description":"Invalid dependency reference or self-reference"},"404":{"description":"Criterion not found"}}},"delete":{"tags":["Criteria"],"summary":"Soft-delete criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Criterion not found"},"409":{"description":"Criterion has dependents"}}}},"/api/v1/prompt-features/generate-prompt":{"post":{"tags":["Prompt Features"],"summary":"Generate prompt feature from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features/seed":{"post":{"tags":["Prompt Features"],"summary":"Seed prompt features in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}},"required":["features"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}}}}},"/api/v1/prompt-features/extract-from-text":{"post":{"tags":["Prompt Features"],"summary":"Extract features from text","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string"},"model":{"type":"string"},"type":{"type":"string","enum":["select","agents.md"]}},"required":["text"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["features","cached"]}}}},"400":{"description":"Empty text string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features":{"get":{"tags":["Prompt Features"],"summary":"List features","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","enum":["select","agents.md"]},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}}}}},"post":{"tags":["Prompt Features"],"summary":"Create feature","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"409":{"description":"Feature already exists"}}}},"/api/v1/prompt-features/{id}":{"get":{"tags":["Prompt Features"],"summary":"Get feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"404":{"description":"Feature not found"}}},"put":{"tags":["Prompt Features"],"summary":"Update feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Feature not found"}}},"delete":{"tags":["Prompt Features"],"summary":"Soft-delete feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Feature not found"}}}},"/api/v1/task-prompts/generate":{"post":{"tags":["Task Prompts"],"summary":"Generate task prompts","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string"},"existingPrompt":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"string"}}},"required":["tasks"]}}}},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts":{"get":{"tags":["Task Prompts"],"summary":"List task prompts","parameters":[{"schema":{"type":["number","null"]},"required":false,"name":"limit","in":"query"},{"schema":{"type":["number","null"]},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"$ref":"#/components/schemas/PromptType"},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TaskPromptResponse"}},"total":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["items","total","limit","offset"]}}}}}},"post":{"tags":["Task Prompts"],"summary":"Create or find task prompt","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTaskPromptInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Empty text string"}}}},"/api/v1/task-prompts/{id}":{"get":{"tags":["Task Prompts"],"summary":"Get task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"404":{"description":"Task prompt not found"}}},"delete":{"tags":["Task Prompts"],"summary":"Soft-delete task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}},"required":["deleted"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/content":{"get":{"tags":["Task Prompts"],"summary":"Get resolved task prompt content","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"}},"required":["id","text"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/extract-features":{"post":{"tags":["Task Prompts"],"summary":"Extract features from task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"force","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"model":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"taskPromptId":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["taskPromptId","features","cached"]}}}},"404":{"description":"Task prompt not found"},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts/{id}/features/{featureId}":{"patch":{"tags":["Task Prompts"],"summary":"Toggle feature flag on task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"featureId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchTaskPromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Invalid detected value"},"404":{"description":"Task prompt or feature not found"}}}},"/api/v1/reports":{"post":{"tags":["Reports"],"summary":"Create report","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Run or template not found"}}},"get":{"tags":["Reports"],"summary":"List reports","parameters":[{"schema":{"type":"string"},"required":false,"name":"requestId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}}}}},"/api/v1/reports/bulk-create":{"post":{"tags":["Reports"],"summary":"Bulk create reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkCreateReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/bulk-status":{"post":{"tags":["Reports"],"summary":"Bulk get report statuses","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportStatusInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/bulk-summary":{"post":{"tags":["Reports"],"summary":"Bulk get report summary per run","description":"Returns aggregated report status counts per run. Only the latest report per template is counted (re-triggers are deduplicated).","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}":{"get":{"tags":["Reports"],"summary":"Get report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"404":{"description":"Report not found"}}}},"/api/v1/reports/{id}/logs":{"get":{"tags":["Reports"],"summary":"Stream report logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Report not found"}}}},"/api/v1/reports/trigger":{"post":{"tags":["Reports"],"summary":"Trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"triggered":{"type":"number"},"reports":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}},"required":["triggered","reports"]}}}},"400":{"description":"Invalid input"},"404":{"description":"Run not found"}}}},"/api/v1/reports/bulk-trigger":{"post":{"tags":["Reports"],"summary":"Bulk trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkTriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}/insights":{"get":{"tags":["Reports"],"summary":"Get insights for report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/InsightResponse"},{"type":"object","properties":{"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}}}]}}}}},"404":{"description":"Report not found"}}},"post":{"tags":["Reports"],"summary":"Link insight to report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"isNew":{"type":"boolean"}},"required":["insightId"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]}}}},"404":{"description":"Report or insight not found"},"409":{"description":"Insight already referenced by this report"}}}},"/api/v1/profiles":{"post":{"tags":["Profiles"],"summary":"Create a new profile","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"get":{"tags":["Profiles"],"summary":"List profiles","parameters":[{"schema":{"type":"string"},"required":false,"name":"workerType","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}":{"get":{"tags":["Profiles"],"summary":"Get profile with latest version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"post":{"tags":["Profiles"],"summary":"Create new profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["workerType","model"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}},"put":{"tags":["Profiles"],"summary":"Update profile identity","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileIdentity"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileResponse"}}}}}},"delete":{"tags":["Profiles"],"summary":"Delete profile","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/profiles/{profileId}/versions":{"get":{"tags":["Profiles"],"summary":"List profile versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}/versions/{version}":{"get":{"tags":["Profiles"],"summary":"Get profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"},{"schema":{"type":["number","null"]},"required":false,"name":"version","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}},"/api/v1/report-templates/default-system-prompt":{"get":{"tags":["Report Templates"],"summary":"Get default system prompt","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string"}},"required":["content"]}}}}}}},"/api/v1/report-templates/available-models":{"get":{"tags":["Report Templates"],"summary":"List models available for report generation","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"modelId":{"type":"string"}},"required":["modelId"]}}}}}}}},"/api/v1/report-templates":{"get":{"tags":["Report Templates"],"summary":"List report templates","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}}}}},"post":{"tags":["Report Templates"],"summary":"Create report template","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportTemplateInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"409":{"description":"Report template already exists"}}}},"/api/v1/report-templates/{id}":{"get":{"tags":["Report Templates"],"summary":"Get report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"put":{"tags":["Report Templates"],"summary":"Update report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateReportTemplateInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"delete":{"tags":["Report Templates"],"summary":"Delete report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Report template not found"}}}},"/api/v1/agents":{"get":{"tags":["Agents"],"summary":"List agents","parameters":[{"schema":{"type":"string"},"required":false,"name":"modelProvider","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentResponse"}}}}}}},"post":{"tags":["Agents"],"summary":"Create or update agent (upsert)","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAgentInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"409":{"description":"Restored agent versions conflict with an active queue owner"}}}},"/api/v1/agents/{id}":{"get":{"tags":["Agents"],"summary":"Get agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"404":{"description":"Agent not found"}}},"put":{"tags":["Agents"],"summary":"Update agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"}}},"delete":{"tags":["Agents"],"summary":"Delete agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Agent not found"}}}},"/api/v1/agents/{id}/versions":{"get":{"tags":["Agents"],"summary":"List agent versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}}}}},"404":{"description":"Agent not found"}}},"post":{"tags":["Agents"],"summary":"Register agent version (upsert)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterAgentVersionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent registration did not stabilize"}}}},"/api/v1/agents/{id}/versions/{agentVersion}":{"patch":{"tags":["Agents"],"summary":"Patch agent version","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"agentVersion","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchAgentVersionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Invalid status value"},"404":{"description":"Agent or version not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent activation did not stabilize"}}}},"/api/v1/models":{"get":{"tags":["Models"],"summary":"List models","parameters":[{"schema":{"type":"string"},"required":false,"name":"agentId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"provider","in":"query"},{"schema":{"type":"string","enum":["active","disappeared"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ModelResponse"}}}}}}}},"/api/v1/models/{id}":{"get":{"tags":["Models"],"summary":"Get model","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelResponse"}}}}}}},"/api/v1/models/sync":{"post":{"tags":["Models"],"summary":"Sync models from provider","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agentId":{"type":"string"},"provider":{"type":"string"},"models":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"providerAvailableFrom":{"type":"string"},"providerEndOfLife":{"type":"string"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}}},"required":["id"]}},"scannedAt":{"type":"string"}},"required":["agentId","provider","models","scannedAt"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"array","items":{"type":"string"}},"removed":{"type":"array","items":{"type":"string"}},"unchanged":{"type":"array","items":{"type":"string"}}},"required":["added","removed","unchanged"]}}}}}}},"/api/v1/mcp/servers":{"get":{"tags":["MCP Servers"],"summary":"List MCP servers","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"post":{"tags":["MCP Servers"],"summary":"Create MCP server","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}},"required":["_id","name","type"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"/api/v1/mcp/servers/{id}":{"get":{"tags":["MCP Servers"],"summary":"Get MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"put":{"tags":["MCP Servers"],"summary":"Update MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMcpServerInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"delete":{"tags":["MCP Servers"],"summary":"Delete MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/skills":{"get":{"tags":["Skills"],"summary":"List all skills","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"post":{"tags":["Skills"],"summary":"Create or import a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSkillInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"/api/v1/skills/search":{"get":{"tags":["Skills"],"summary":"Search skills (internal + external)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/search/external":{"get":{"tags":["Skills"],"summary":"Search external skills registry","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project."},"required":false,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/discover":{"get":{"tags":["Skills"],"summary":"Discover skills in a GitHub repository","parameters":[{"schema":{"type":"string"},"required":true,"name":"source","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillDiscoveryResult"}}}}},"400":{"description":"Missing or malformed source parameter"},"404":{"description":"Repository not found"},"502":{"description":"GitHub API error"}}}},"/api/v1/skills/{id}(*)/revisions":{"get":{"tags":["Skills"],"summary":"List skill revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}}},"404":{"description":"Skill not found"}}}},"/api/v1/skills/{id}(*)":{"get":{"tags":["Skills"],"summary":"Get skill by slug","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}},"404":{"description":"Skill not found"}}},"delete":{"tags":["Skills"],"summary":"Soft-delete a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success"},"404":{"description":"Skill not found"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)/archive":{"get":{"tags":["Skill Revisions"],"summary":"Download skill revision archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Skill revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ref","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skill-revisions/{id}":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ID","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skills/{id}(*)/resolve":{"post":{"tags":["Skills"],"summary":"Trigger skill resolution","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebases":{"get":{"tags":["Codebases"],"summary":"List all codebases","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseResponse"}}}}}}},"post":{"tags":["Codebases"],"summary":"Create a codebase (archive codebases require the archive file)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input or missing archive"}}}},"/api/v1/codebases/{id}":{"get":{"tags":["Codebases"],"summary":"Get a codebase","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"patch":{"tags":["Codebases"],"summary":"Update a codebase","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCodebaseInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"delete":{"tags":["Codebases"],"summary":"Delete a codebase","responses":{"204":{"description":"Success"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebases/{id}/revisions":{"get":{"tags":["Codebases"],"summary":"List codebase revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}}},"404":{"description":"Codebase not found"}}},"post":{"tags":["Codebases"],"summary":"Resolve a new git codebase revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolveCodebaseRevisionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"400":{"description":"Not a git codebase"},"404":{"description":"Codebase not found"},"502":{"description":"GitHub resolution failed"}}}},"/api/v1/codebases/{id}/upload":{"post":{"tags":["Codebases"],"summary":"Upload a codebase archive as a new revision","responses":{"201":{"description":"Success"},"400":{"description":"Missing archive or not an archive codebase"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebase-revisions/{id}/archive":{"get":{"tags":["Codebase Revisions"],"summary":"Download codebase revision archive","responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebase-revisions/{id}":{"get":{"tags":["Codebase Revisions"],"summary":"Get codebase revision by id","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/extensions":{"get":{"tags":["Extensions"],"summary":"List all extensions","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"post":{"tags":["Extensions"],"summary":"Create or import an extension","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExtensionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"/api/v1/extensions/search":{"get":{"tags":["Extensions"],"summary":"Search extensions (internal + marketplace)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/extensions/{id}":{"get":{"tags":["Extensions"],"summary":"Get extension by ID","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"put":{"tags":["Extensions"],"summary":"Update extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateExtensionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"delete":{"tags":["Extensions"],"summary":"Delete extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/extensions/{id}/versions":{"get":{"tags":["Extensions"],"summary":"List available versions for an extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"preRelease","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionVersionInfo"}}}}}}}},"/api/v1/insights":{"get":{"tags":["Insights"],"summary":"List insights","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"post":{"tags":["Insights"],"summary":"Create insight","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInsightInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"/api/v1/insights/search":{"get":{"tags":["Insights"],"summary":"Search insights","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}}},"/api/v1/insights/{id}":{"get":{"tags":["Insights"],"summary":"Get insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"put":{"tags":["Insights"],"summary":"Update insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateInsightInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"delete":{"tags":["Insights"],"summary":"Delete insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/upvote":{"post":{"tags":["Insights"],"summary":"Upvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/downvote":{"post":{"tags":["Insights"],"summary":"Downvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/block":{"post":{"tags":["Insights"],"summary":"Block insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/unblock":{"post":{"tags":["Insights"],"summary":"Unblock insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/reports":{"get":{"tags":["Insights"],"summary":"Get reports referencing insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Insight not found"}}}},"/api/v1/feature-flags":{"get":{"tags":["Feature Flags"],"summary":"List feature flags","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"/api/v1/feature-flags/{key}":{"put":{"tags":["Feature Flags"],"summary":"Update feature flag","parameters":[{"schema":{"type":"string"},"required":true,"name":"key","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFeatureFlagInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"webhooks":{}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Scope API","version":"1.0.0","description":"REST API for the Scope platform — benchmarking AI coding agents"},"servers":[{"url":"/","description":"Current server"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Use the unchanged IdP access token, without the Bearer prefix."}},"schemas":{"KeyResponse":{"type":"object","properties":{},"additionalProperties":{}},"KeyInput":{"type":"object","properties":{},"additionalProperties":{}},"ValidateKeyInput":{"type":"object","properties":{"token":{"type":"string"}},"required":["token"]},"AccountResponse":{"type":"object","properties":{},"additionalProperties":{}},"AccountInput":{"type":"object","properties":{},"additionalProperties":{}},"UserMeResponse":{"type":"object","properties":{"id":{"type":"string"},"role":{"type":"string"},"email":{"type":"string"},"displayName":{"type":"string"},"idp":{"type":"string"},"idpTenant":{"type":"string"}},"required":["id"]},"ProjectResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","createdAt"]},"CreateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"creator":{"type":"string"}},"required":["name"]},"UpdateProjectInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"RequestResponse":{"type":"object","properties":{"_id":{"type":"string"},"scenario":{"$ref":"#/components/schemas/Scenario"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"maxIterations":{"type":"number"},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"deletedAt":{"type":["string","null"],"format":"date-time"},"taskPromptId":{"type":"string"},"agentsMdPromptId":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"profileId":{"type":"string"},"profileVersionId":{"type":"string"},"submissionId":{"type":"string"},"priority":{"type":"integer","default":0},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}},"gateSummaries":{"type":"array","items":{"$ref":"#/components/schemas/GateRunSummary"}},"run":{"$ref":"#/components/schemas/RunState"},"projectId":{"type":"string"}},"required":["_id","scenario","workerType","createdAt","projectId"]},"Scenario":{"type":"object","properties":{"version":{"type":"string","enum":["v1","v2"]},"task":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}}},"required":["task","criteria"]},"Persona":{"type":"object","properties":{"personality":{"type":"string","enum":["demanding","friendly"]},"experience":{"type":"string","enum":["junior","senior"]},"verbosity":{"type":"string","enum":["brief","moderate"]},"type":{"type":"string","enum":["traditional","ai_assisted","vibe"]}},"required":["personality","experience","verbosity","type"]},"GateConfig":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"promptId":{"type":"string"},"promptText":{"type":"string"},"criteria":{"type":"array","items":{"type":"string"}},"maxIterations":{"type":"integer","minimum":1,"maximum":50}},"required":["gate","criteria"]},"GateRunSummary":{"type":"object","properties":{"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"status":{"type":"string","enum":["passed","failed","skipped"]},"iterations":{"type":"integer","minimum":0}},"required":["gate","status","iterations"]},"RunState":{"type":"object","properties":{"_id":{"type":"string"},"attemptNumber":{"type":"integer","minimum":1},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"queuedQueueName":{"type":"string"},"outcome":{"type":"string","enum":["succeeded","failed","finished"]},"result":{"type":"string"},"error":{"type":"string"},"logsUrl":{"type":"string"},"updatedAt":{"type":["string","null"],"format":"date-time"},"startedAt":{"type":["string","null"],"format":"date-time"},"finishedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"turns":{"type":"array","items":{"$ref":"#/components/schemas/ConversationTurn"}},"workerVersion":{"type":"string"},"os":{"type":"object","properties":{"platform":{"type":"string"},"release":{"type":"string"},"arch":{"type":"string"}},"required":["platform","release","arch"]},"lastHeartbeatAt":{"type":["string","null"],"format":"date-time"},"worker":{"type":"object","properties":{"instanceId":{"type":"string"},"podName":{"type":"string"}},"required":["instanceId"]},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"setupVideoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"aiCallCount":{"type":"number"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"pausedAt":{"type":["string","null"],"format":"date-time"},"resumedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","attemptNumber","status"]},"ConversationTurn":{"type":"object","properties":{"iteration":{"type":"number"},"gate":{"type":"string","enum":["select","build","test","run","deploy"]},"codingAgentResponse":{"type":"string"},"judgeFeedback":{"type":"string"},"snapshotUrl":{"type":"string"},"passed":{"type":"boolean"},"timestamp":{"type":["string","null"],"format":"date-time"},"criteriaResults":{"type":"array","items":{"$ref":"#/components/schemas/CriterionResult"}},"harUrl":{"type":"string"},"videoUrls":{"type":"array","items":{"type":"string"}},"tokenUsage":{"$ref":"#/components/schemas/TokenUsage"},"startedAt":{"type":["string","null"],"format":"date-time"},"durationMs":{"type":"number"},"toolCalls":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"arguments":{"type":"object","additionalProperties":{}},"response":{"type":"string"},"timestamp":{"type":"string"}},"required":["id","name","arguments"]}},"toolCallsUrl":{"type":"string"},"toolCallCount":{"type":"number"},"aiCallCount":{"type":"number"},"rawChatUrl":{"type":"string"},"rawChatFormat":{"type":"string"},"chatResultUrl":{"type":"string"},"chatResultFormat":{"type":"string"}},"required":["iteration","judgeFeedback","snapshotUrl","passed","timestamp"]},"CriterionResult":{"type":"object","properties":{"criterionId":{"type":"string"},"passed":{"type":"boolean"},"feedback":{"type":"string"},"evaluated":{"type":"boolean"}},"required":["criterionId","passed","feedback","evaluated"]},"TokenUsage":{"type":"object","properties":{"promptTokens":{"type":"number"},"completionTokens":{"type":"number"},"totalTokens":{"type":"number"}},"required":["promptTokens","completionTokens","totalTokens"]},"CreateRequestInput":{"type":"object","properties":{"scenario":{"$ref":"#/components/schemas/Scenario"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"maxIterations":{"type":"integer","minimum":1,"maximum":50},"personaInstructions":{"type":"string"},"persona":{"$ref":"#/components/schemas/Persona"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"profileId":{"type":"string"},"profileVariations":{"type":"array","items":{"type":"string"}},"priority":{"type":"integer"},"agentsMd":{"type":"string"},"agentsMdParentIds":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"$ref":"#/components/schemas/GateConfig"}}},"required":["scenario"]},"RunFacetsResponse":{"type":"object","properties":{"total":{"type":"number"},"facets":{"type":"object","properties":{"workerType":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"status":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"outcome":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"model":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"os":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"priority":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"agentVersion":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}},"profileId":{"type":"array","items":{"$ref":"#/components/schemas/RunFacetBucket"}}},"required":["workerType","status","outcome","model","os","priority","agentVersion","profileId"]}},"required":["total","facets"]},"RunFacetBucket":{"type":"object","properties":{"value":{"type":"string"},"count":{"type":"number"}},"required":["value","count"]},"PaginatedRunsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"Cursors":{"type":"object","properties":{"next":{"type":["string","null"]},"prev":{"type":["string","null"]}},"required":["next","prev"]},"PaginatedRunGroupsResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/RunGroup"}},"limit":{"type":"number"},"estimatedTotal":{"type":"number"},"cursors":{"$ref":"#/components/schemas/Cursors"}},"required":["data","limit","estimatedTotal","cursors"]},"RunGroup":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"runIds":{"type":"array","items":{"type":"string"}},"aggregates":{"$ref":"#/components/schemas/GroupAggregates"},"uniform":{"$ref":"#/components/schemas/GroupUniformValues"}},"required":["key","label","runIds","aggregates","uniform"]},"GroupAggregates":{"type":"object","properties":{"count":{"type":"number"},"turns":{"$ref":"#/components/schemas/AggregateStats"},"duration":{"$ref":"#/components/schemas/AggregateStats"},"promptTokens":{"$ref":"#/components/schemas/AggregateStats"},"completionTokens":{"$ref":"#/components/schemas/AggregateStats"},"statusCounts":{"type":"object","additionalProperties":{"type":"number"}},"outcomeCounts":{"type":"object","additionalProperties":{"type":"number"}}},"required":["count","turns","duration","promptTokens","completionTokens","statusCounts","outcomeCounts"]},"AggregateStats":{"type":["object","null"],"properties":{"min":{"type":"number"},"max":{"type":"number"},"mean":{"type":"number"},"stdDev":{"type":"number"}},"required":["min","max","mean","stdDev"]},"GroupUniformValues":{"type":"object","properties":{"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":"string"},"platform":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"codebaseRevisionId":{"type":"string"},"extensions":{"type":"array","items":{"type":"string"}},"status":{"type":"string","enum":["pending","queued","processing","paused","done"]},"submissionId":{"type":"string"},"task":{"type":"string"}}},"BulkResubmitInput":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"count":{"type":"integer","minimum":1,"maximum":10,"default":1},"overrides":{"type":"object","properties":{"profileId":{"type":["string","null"]},"workerType":{"type":"string"},"agentVersion":{"type":"string"},"model":{"type":["string","null"]},"reasoningEffort":{"type":["string","null"]},"maxIterations":{"type":["number","null"]},"mcpServers":{"type":["array","null"],"items":{"type":"string"}},"skillRevisions":{"type":["array","null"],"items":{"type":"string"}},"extensions":{"type":["array","null"],"items":{"type":"string"}}}}},"required":["ids"]},"ReportResponse":{"type":"object","properties":{"_id":{"type":"string"},"requestId":{"type":"string"},"templateId":{"type":"string"},"reporter":{"$ref":"#/components/schemas/Reporter"},"content":{"type":"string"},"status":{"type":"string","enum":["pending","generating","completed","failed"]},"error":{"type":"string"},"insightReferences":{"type":"array","items":{"$ref":"#/components/schemas/InsightReference"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","requestId","status","createdAt","projectId"]},"Reporter":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"gitHash":{"type":"string"},"model":{"type":"string"},"agentId":{"type":"string"},"agentVersion":{"type":"string"}},"required":["id","name","gitHash","model","agentId","agentVersion"]},"InsightReference":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]},"CreateCriteriaInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}}},"required":["id","prompt"]},"CriteriaResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"CriteriaGraph":{"type":"object","properties":{"nodes":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphNode"}},"edges":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaGraphEdge"}}},"required":["nodes","edges"]},"CriteriaGraphNode":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}}},"required":["id","prompt"]},"CriteriaGraphEdge":{"type":"object","properties":{"from":{"type":"string"},"to":{"type":"string"}},"required":["from","to"]},"UpdateCriteriaInput":{"type":"object","properties":{"prompt":{"type":"string"},"dependsOn":{"type":"array","items":{"type":"string"}},"gates":{"type":"array","items":{"type":"string","enum":["select","build","test","run","deploy"]}}}},"CreatePromptFeatureInput":{"type":"object","properties":{"id":{"type":"string","pattern":"^[a-z][a-z0-9_]*$"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["id","prompt"]},"PromptType":{"type":"string","enum":["select","build","test","run","deploy","agents.md"]},"PromptFeatureResult":{"type":"object","properties":{"featureId":{"type":"string"},"detected":{"type":"boolean"},"evaluated":{"type":"boolean"}},"required":["featureId","detected","evaluated"]},"SuggestedPromptFeature":{"type":"object","properties":{"suggestedId":{"type":"string"},"behavior":{"type":"string"},"prompt":{"type":"string"}},"required":["suggestedId","behavior","prompt"]},"PromptFeatureResponse":{"type":"object","properties":{"id":{"type":"string"},"prompt":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["id","prompt","createdAt","projectId"]},"UpdatePromptFeatureInput":{"type":"object","properties":{"prompt":{"type":"string"}}},"TaskPromptResponse":{"type":"object","properties":{"_id":{"type":"string"},"keyId":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"},"text":{"type":"string"},"contentBlobUrl":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","keyId","createdAt","projectId"]},"CreateTaskPromptInput":{"type":"object","properties":{"text":{"type":"string"},"type":{"$ref":"#/components/schemas/PromptType"}},"required":["text"]},"PatchTaskPromptFeatureInput":{"type":"object","properties":{"detected":{"type":"boolean"}},"required":["detected"]},"CreateReportInput":{"type":"object","properties":{"requestId":{"type":"string"},"templateId":{"type":"string"}},"required":["requestId"]},"BulkCreateReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}},"templateId":{"type":"string"}},"required":["requestIds"]},"BulkReportStatusInput":{"type":"object","properties":{"reportIds":{"type":"array","items":{"type":"string"}}},"required":["reportIds"]},"BulkReportSummaryResponse":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/ReportSummary"}},"ReportSummary":{"type":"object","properties":{"total":{"type":"number"},"pending":{"type":"number"},"generating":{"type":"number"},"completed":{"type":"number"},"failed":{"type":"number"}},"required":["total","pending","generating","completed","failed"]},"BulkReportSummaryInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"TriggerReportsInput":{"type":"object","properties":{"requestId":{"type":"string"}},"required":["requestId"]},"BulkTriggerReportsInput":{"type":"object","properties":{"requestIds":{"type":"array","items":{"type":"string"}}},"required":["requestIds"]},"InsightResponse":{"type":"object","properties":{"_id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"upvotes":{"type":"number"},"downvotes":{"type":"number"},"blocked":{"type":"boolean"},"referenceCount":{"type":"number"},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","title","description","upvotes","downvotes","blocked","referenceCount","createdBy","createdAt","projectId"]},"ProfileWithVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"version":{"$ref":"#/components/schemas/ProfileVersionResponse"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","version","projectId"]},"ProfileVersionResponse":{"type":"object","properties":{"_id":{"type":"string"},"profileId":{"type":"string"},"version":{"type":"number"},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","profileId","version","workerType","model","createdAt","projectId"]},"CreateProfileInput":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512},"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["name","workerType","model"]},"ProfileResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"latestVersion":{"type":"number"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","name","latestVersion","createdAt","projectId"]},"UpdateProfileIdentity":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":128},"description":{"type":"string","maxLength":512}}},"ReportTemplateResponse":{"type":"object","properties":{"_id":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","id","name","userPrompt","createdAt","projectId"]},"ReportTemplateSystemPrompt":{"type":"object","properties":{"mode":{"type":"string","enum":["append","override"]},"content":{"type":"string"}},"required":["mode","content"]},"ReportTrigger":{"oneOf":[{"type":"object","properties":{"type":{"type":"string","enum":["always"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["criteria"]},"criteriaIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","criteriaIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["taskPrompt"]},"taskPromptIds":{"type":"array","items":{"type":"string"}}},"required":["type","taskPromptIds"]},{"type":"object","properties":{"type":{"type":"string","enum":["promptFeature"]},"featureIds":{"type":"array","items":{"type":"string"}},"match":{"type":"string","enum":["any","all"]}},"required":["type","featureIds"]}]},"CreateReportTemplateInput":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":"string"},"timeoutMs":{"type":"integer","exclusiveMinimum":0}},"required":["id","name","userPrompt"]},"UpdateReportTemplateInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"userPrompt":{"type":"string"},"systemPrompt":{"allOf":[{"$ref":"#/components/schemas/ReportTemplateSystemPrompt"},{"type":["object","null"]}]},"trigger":{"$ref":"#/components/schemas/ReportTrigger"},"model":{"type":["string","null"]},"timeoutMs":{"type":["integer","null"],"exclusiveMinimum":0}}},"AgentResponse":{"type":"object","properties":{"_id":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"},"versions":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"}},"required":["_id","name","supportedModels","createdAt"]},"AgentCapabilities":{"type":"object","properties":{"supportsReasoningEffort":{"type":"boolean"},"supportsMcpServers":{"type":"boolean"},"supportsSkills":{"type":"boolean"},"supportsExtensions":{"type":"boolean"}}},"AgentVersion":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string"},"status":{"type":"string","enum":["active","retired"]},"createdAt":{"type":["string","null"],"format":"date-time"}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","status","createdAt"]},"CreateAgentInput":{"type":"object","properties":{"_id":{"type":"string","minLength":1},"name":{"type":"string","minLength":1},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}},"required":["_id","name"]},"UpdateAgentInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"},"modelProvider":{"type":"string"},"supportedModels":{"type":"array","items":{"type":"string"}},"defaultModel":{"type":"string"},"available":{"type":"boolean"},"capabilities":{"$ref":"#/components/schemas/AgentCapabilities"}}},"RegisterAgentVersionInput":{"type":"object","properties":{"agentVersion":{"type":"string","minLength":1},"workerVersion":{"type":"string","minLength":1},"components":{"type":"object","additionalProperties":{"type":"string"}},"gitCommit":{"type":"string","minLength":1},"buildTime":{"type":"string","minLength":1},"imageTag":{"type":"string","minLength":1},"queueName":{"type":"string","minLength":1}},"required":["agentVersion","workerVersion","components","gitCommit","buildTime","imageTag","queueName"]},"PatchAgentVersionInput":{"type":"object","properties":{"status":{"type":"string","enum":["active","retired"]}},"required":["status"]},"ModelResponse":{"type":"object","properties":{"_id":{"type":"string"},"modelId":{"type":"string"},"provider":{"type":"string"},"agentId":{"type":"string"},"firstSeenAt":{"type":["string","null"],"format":"date-time"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"disappearedAt":{"type":["string","null"],"format":"date-time"},"providerAvailableFrom":{"type":["string","null"],"format":"date-time"},"providerEndOfLife":{"type":["string","null"],"format":"date-time"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"$ref":"#/components/schemas/ModelCapabilities"}},"required":["_id","modelId","provider","agentId","firstSeenAt","lastSeenAt"]},"ModelCapabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}},"McpServerResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","type","createdAt","projectId"]},"McpServerHeader":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}},"required":["name","value"]},"UpdateMcpServerInput":{"type":"object","properties":{"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}}},"SkillResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","source","skillName","name","origin","createdAt","projectId"]},"SkillSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"source":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"installs":{"type":"number"}},"required":["id","name","source","internal"]},"SkillDiscoveryResult":{"type":"object","properties":{"skillName":{"type":"string"},"skillPath":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"existsInLibrary":{"type":"boolean"},"currentRevisionCommitSha":{"type":"string"},"latestUpstreamCommitSha":{"type":"string"},"updateAvailable":{"type":"boolean"},"lastImportedAt":{"type":"string"}},"required":["skillName","skillPath"]},"SkillRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"ref":{"type":"string"},"source":{"type":"string"},"skillName":{"type":"string"},"skillPath":{"type":"string"},"commitHash":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"name":{"type":"string"},"description":{"type":"string"},"license":{"type":"string"},"compatibility":{"type":"string"},"allowedTools":{"type":"string"},"metadata":{"type":"object","additionalProperties":{"type":"string"}},"content":{"type":"string"},"archiveUrl":{"type":"string"},"validationWarnings":{"type":"array","items":{"type":"string"}},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","ref","source","skillName","skillPath","commitHash","commitTimestamp","name","description","content","archiveUrl","resolvedAt","createdAt","projectId"]},"CreateSkillInput":{"type":"object","properties":{"source":{"type":"string"},"skillName":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["skills-sh","manual"]}},"required":["source","skillName","name","origin"]},"CodebaseResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"defaultBranch":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","name","sourceType","revisionCounter","createdAt","projectId"]},"UpdateCodebaseInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"defaultBranch":{"type":"string"}}},"CodebaseRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"codebaseId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"sourceType":{"type":"string","enum":["git","archive"]},"source":{"type":"string"},"requestedRef":{"type":"string"},"resolvedCommitSha":{"type":"string"},"commitTimestamp":{"type":["string","null"],"format":"date-time"},"originalFilename":{"type":"string"},"contentSha256":{"type":"string"},"archiveUrl":{"type":"string"},"sizeBytes":{"type":"number"},"fileCount":{"type":"number"},"creator":{"type":"string"},"resolvedAt":{"type":["string","null"],"format":"date-time"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","codebaseId","slug","revisionNumber","ref","sourceType","archiveUrl","resolvedAt","createdAt","projectId"]},"ResolveCodebaseRevisionInput":{"type":"object","properties":{"requestedRef":{"type":"string"},"creator":{"type":"string"}}},"ExtensionResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"},"deletedAt":{"type":["string","null"],"format":"date-time"},"projectId":{"type":"string"}},"required":["_id","slug","publisher","name","origin","createdAt","projectId"]},"ExtensionSearchResult":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"publisher":{"type":"string"},"description":{"type":"string"},"internal":{"type":"boolean"},"version":{"type":"string"}},"required":["id","name","publisher","internal"]},"CreateExtensionInput":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$"},"publisher":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"origin":{"type":"string","enum":["marketplace","manual"]}},"required":["_id","publisher","name","origin"]},"UpdateExtensionInput":{"type":"object","properties":{"name":{"type":"string"},"description":{"type":"string"}}},"ExtensionVersionInfo":{"type":"object","properties":{"version":{"type":"string"},"preRelease":{"type":"boolean"},"lastUpdated":{"type":"string"}},"required":["version","preRelease","lastUpdated"]},"CreateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}},"createdBy":{"type":"string","enum":["agent","user"]},"sourceReportId":{"type":"string"}},"required":["title","description"]},"UpdateInsightInput":{"type":"object","properties":{"title":{"type":"string"},"description":{"type":"string"},"category":{"type":"string"},"tags":{"type":"array","items":{"type":"string"}}}},"FeatureFlagResponse":{"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"enabled":{"type":"boolean"},"updatedAt":{"type":["string","null"],"format":"date-time"}},"required":["key","label","enabled","updatedAt"]},"UpdateFeatureFlagInput":{"type":"object","properties":{"enabled":{"type":"boolean"}},"required":["enabled"]}},"parameters":{}},"paths":{"/api/v1/keys/preview":{"post":{"tags":["Keys"],"summary":"Preview key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key preview","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}}},"/api/v1/keys":{"post":{"tags":["Keys"],"summary":"Create key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"201":{"description":"Key created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"get":{"tags":["Keys"],"summary":"List keys","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/KeyResponse"}}}}}}}},"/api/v1/keys/{id}":{"get":{"tags":["Keys"],"summary":"Get key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"put":{"tags":["Keys"],"summary":"Update key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyInput"}}}},"responses":{"200":{"description":"Key updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyResponse"}}}}}},"delete":{"tags":["Keys"],"summary":"Delete key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Key deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/api/v1/keys/{id}/validate":{"post":{"tags":["Keys"],"summary":"Validate key","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateKeyInput"}}}},"responses":{"200":{"description":"Validation result","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Key validation result"}}}}}}},"/api/v1/accounts":{"post":{"tags":["Accounts"],"summary":"Create account","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"201":{"description":"Account created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"get":{"tags":["Accounts"],"summary":"List accounts","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AccountResponse"}}}}}}}},"/api/v1/accounts/{id}":{"get":{"tags":["Accounts"],"summary":"Get account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"put":{"tags":["Accounts"],"summary":"Update account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountInput"}}}},"responses":{"200":{"description":"Account updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountResponse"}}}}}},"delete":{"tags":["Accounts"],"summary":"Delete account","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Account deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}},"required":["success"]}}}}}}},"/api/v1/users/me":{"get":{"tags":["Users"],"summary":"Get the authenticated user's identity","description":"Read the existing authenticated Scope identity without enrollment or profile writes. Responses must not be HTTP-cached.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMeResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"User is not enrolled or is disabled"},"503":{"description":"Authentication service unavailable"}}},"post":{"tags":["Users"],"summary":"Enroll the authenticated user","description":"JIT-enroll the authenticated user, refresh their profile and lastLoginAt, apply bootstrap-admin rules, and warm the access cache. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserMeResponse"}}}},"401":{"description":"Not authenticated"},"403":{"description":"User is disabled"},"503":{"description":"Authentication service unavailable"}}}},"/health":{"get":{"tags":["Health"],"summary":"Liveness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"version":{"type":"string"}},"required":["status","version"]}}}}}}},"/ready":{"get":{"tags":["Health"],"summary":"Readiness probe","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"migrations":{}},"required":["status"]}}}},"503":{"description":"Service is not ready"}}}},"/about":{"get":{"tags":["System"],"summary":"API metadata","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"version":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"description":{"type":"string"},"workers":{"type":"array","items":{"type":"string"}}},"required":["name","version","buildTime","environment","description","workers"]}}}}}}},"/api/v1/version":{"get":{"tags":["System"],"summary":"Version info","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"commit":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"strictAgentCapabilities":{"type":"boolean"}},"required":["commit","buildTime","environment","strictAgentCapabilities"]}}}}}}},"/api/v1/projects":{"get":{"tags":["Projects"],"summary":"List all projects","parameters":[{"schema":{"type":"string","enum":["true","false"],"description":"When true, include soft-deleted projects in the result"},"required":false,"description":"When true, include soft-deleted projects in the result","name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}}}}}},"post":{"tags":["Projects"],"summary":"Create a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProjectInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/projects/{id}":{"get":{"tags":["Projects"],"summary":"Get a project","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"patch":{"tags":["Projects"],"summary":"Update a project","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProjectInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"delete":{"tags":["Projects"],"summary":"Soft-delete a project","responses":{"204":{"description":"Success"},"404":{"description":"Project not found"}}}},"/api/v1/projects/{id}/restore":{"post":{"tags":["Projects"],"summary":"Restore a soft-deleted project","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}}},"/api/v1/requests":{"post":{"tags":["Requests"],"summary":"Submit request(s)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/CreateRequestInput"},{"type":"object","properties":{"count":{"type":"number","minimum":1,"maximum":10,"default":1},"skills":{"type":"array","items":{"type":"string"}},"agentVersion":{"type":"string"},"codebase":{"type":"string"}}}]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/RequestResponse"},{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}]}}}}}},"get":{"tags":["Requests"],"summary":"List requests","parameters":[{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"profileId","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"status","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"outcome","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"model","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"os","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"priority","in":"query"},{"schema":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"required":false,"name":"agentVersion","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"type":"string","enum":["task","submissionId","profile"]},"required":false,"name":"groupBy","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":100},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string"},"required":false,"name":"after","in":"query"},{"schema":{"type":"string"},"required":false,"name":"before","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"last","in":"query"},{"schema":{"type":"string","enum":["created","updated","priority","worker","status","id","duration","createdAt"]},"required":false,"name":"sortBy","in":"query"},{"schema":{"type":"string","enum":["asc","desc"]},"required":false,"name":"sortDir","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdAfter","in":"query"},{"schema":{"type":["string","null"],"format":"date-time"},"required":false,"name":"createdBefore","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"turns","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"turnsOp","in":"query"},{"schema":{"type":["integer","null"],"minimum":0},"required":false,"name":"maxIterations","in":"query"},{"schema":{"type":"string","enum":["eq","gte","lte"]},"required":false,"name":"maxIterationsOp","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/PaginatedRunsResponse"},{"$ref":"#/components/schemas/PaginatedRunGroupsResponse"}]}}}}}}},"/api/v1/requests/facets":{"get":{"tags":["Requests"],"summary":"List run filter facets","description":"Returns every distinct value and its full-dataset count per categorical filter dimension for the Runs list rail. Counts are absolute over all non-deleted runs **in the given project**: they intentionally ignore the active search, date range, iteration, and categorical selections so every selectable value stays visible with a stable count. Requires ?projectId=. Computed with parallel $group aggregations (Cosmos has no $facet) and cached per-project for a short TTL.","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunFacetsResponse"}}}}}}},"/api/v1/requests/{id}":{"get":{"tags":["Requests"],"summary":"Get request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RequestResponse"}}}},"404":{"description":"Not found"}}},"delete":{"tags":["Requests"],"summary":"Soft-delete request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Not found"}}}},"/api/v1/analysis":{"get":{"tags":["Requests"],"summary":"Compute pass@k / success@T metrics","description":"Aggregates pass@k / success@T metrics over a single project's done runs. Requires ?projectId=.","parameters":[{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"submissionId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"k","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{},"description":"Analysis metrics"}}}}}}},"/api/v1/requests/bulk-resubmit":{"post":{"tags":["Requests"],"summary":"Bulk resubmit requests","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkResubmitInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RequestResponse"}}}}}}}},"/api/v1/requests/bulk":{"delete":{"tags":["Requests"],"summary":"Bulk soft-delete requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"}}},"required":["ids"]}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"number"}},"required":["deleted"]}}}}}}},"/api/v1/requests/archive":{"post":{"tags":["Requests"],"summary":"Download batch archive of multiple runs","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Gzipped batch archive containing individual run archives"},"400":{"description":"Invalid input"},"404":{"description":"One or more runs not found"}}}},"/api/v1/runs/upload":{"post":{"tags":["Requests"],"summary":"Import run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"}}}},"/api/v1/runs/upload-batch":{"post":{"tags":["Requests"],"summary":"Import batch run archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"207":{"description":"Multi-status: 201 if all runs imported, 400 if none imported, 207 if partial"},"400":{"description":"No archive uploaded, empty archive, or all runs failed"}}}},"/api/v1/requests/{id}/reports":{"get":{"tags":["Reports"],"summary":"Get reports for request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Run not found"}}}},"/api/v1/requests/{id}/runs":{"get":{"tags":["Requests"],"summary":"List attempts for a request","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/RunState"}}}}},"404":{"description":"Request not found"}}}},"/api/v1/requests/{id}/runs/{runId}":{"get":{"tags":["Requests"],"summary":"Get a single attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunState"}}}},"404":{"description":"Request or run not found"}}}},"/api/v1/requests/bulk-retry":{"post":{"tags":["Requests"],"summary":"Bulk retry requests (start new attempts)","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1},"force":{"type":"boolean"}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"retried":{"type":"integer"},"skipped":{"type":"integer"},"results":{"type":"array","items":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"},"error":{"type":"string"}},"required":["requestId"]}}},"required":["retried","skipped","results"]}}}}}}},"/api/v1/requests/{id}/retry":{"post":{"tags":["Requests"],"summary":"Retry a request (start a new attempt)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"force":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"requestId":{"type":"string"},"runId":{"type":"string"},"attemptNumber":{"type":"integer"}},"required":["requestId","runId","attemptNumber"]}}}},"404":{"description":"Request not found"},"409":{"description":"Conflict — request is not in a retryable state, or a concurrent retry won the race"},"422":{"description":"Cannot retry — current run not yet terminal"}}}},"/api/v1/requests/{id}/priority":{"post":{"tags":["Requests"],"summary":"Set priority on a single request","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"priority":{"type":"integer"}},"required":["priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"priority":{"type":"number"}},"required":["id","priority"]}}}}}}},"/api/v1/requests/bulk-priority":{"post":{"tags":["Requests"],"summary":"Set priority on multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100},"priority":{"type":"integer"}},"required":["ids","priority"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/pause":{"post":{"tags":["Requests"],"summary":"Pause a single request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/{id}/resume":{"post":{"tags":["Requests"],"summary":"Resume a paused request","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string"}},"required":["id","status"]}}}}}}},"/api/v1/requests/bulk-pause":{"post":{"tags":["Requests"],"summary":"Pause multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/bulk-resume":{"post":{"tags":["Requests"],"summary":"Resume multiple paused requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"number"},"skipped":{"type":"number"}},"required":["updated","skipped"]}}}}}}},"/api/v1/requests/{id}/cancel":{"post":{"tags":["Requests"],"summary":"Cancel a request (marks as done/failed and signals worker to exit)","responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"previousStatus":{"type":"string"},"status":{"type":"string"},"outcome":{"type":"string"}},"required":["id","previousStatus","status","outcome"]}}}}}}},"/api/v1/requests/bulk-cancel":{"post":{"tags":["Requests"],"summary":"Cancel multiple requests","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"ids":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}},"required":["ids"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"cancelled":{"type":"number"},"skipped":{"type":"number"},"results":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"cancelled":{"type":"boolean"},"previousStatus":{"type":"string"},"error":{"type":"string"}},"required":["id","cancelled"]}}},"required":["cancelled","skipped","results"]}}}}}}},"/api/v1/requests/{id}/logs":{"get":{"tags":["Requests"],"summary":"Stream request logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/logs":{"get":{"tags":["Requests"],"summary":"Stream logs for a specific attempt (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/har":{"get":{"tags":["Requests"],"summary":"Download HAR file for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"HAR-format JSON file"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/atif":{"get":{"tags":["Requests"],"summary":"Download ATIF trajectory file for a specific attempt and iteration","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string","description":"The iteration number (1-based)"},"required":true,"description":"The iteration number (1-based)","name":"iteration","in":"query"}],"responses":{"200":{"description":"ATIF v1.7 trajectory JSON file"},"400":{"description":"Missing or invalid iteration"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/video":{"get":{"tags":["Requests"],"summary":"Download session recording","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/video":{"get":{"tags":["Requests"],"summary":"Download session recording for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"WebM video recording (supports Range requests)"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/tool-calls":{"get":{"tags":["Requests"],"summary":"Download per-iteration tool-calls JSONL for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"JSONL stream — one ToolCall per line"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/snapshots/{iteration}":{"get":{"tags":["Requests"],"summary":"Download iteration snapshot for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"},{"schema":{"type":"string"},"required":true,"name":"iteration","in":"path"}],"responses":{"200":{"description":"Gzipped snapshot archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/requests/{id}/runs/{runId}/archive":{"get":{"tags":["Requests"],"summary":"Download full run archive for a specific attempt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"runId","in":"path"}],"responses":{"200":{"description":"Gzipped run archive"},"404":{"description":"Not found"}}}},"/api/v1/criteria/generate-prompt":{"post":{"tags":["Criteria"],"summary":"Generate criterion prompt from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"},"gates":{"type":"array","items":{"type":"string"}}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"},"suggestedId":{"type":"string"},"suggestedParents":{"type":"array","items":{"type":"string"}},"suggestedChildren":{"type":"array","items":{"type":"string"}}},"required":["prompt","suggestedId","suggestedParents","suggestedChildren"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/criteria/seed":{"post":{"tags":["Criteria"],"summary":"Seed criteria in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"criteria":{"type":"array","items":{"$ref":"#/components/schemas/CreateCriteriaInput"}}},"required":["criteria"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}},"400":{"description":"Seed batch would introduce a dependency cycle"}}}},"/api/v1/criteria":{"get":{"tags":["Criteria"],"summary":"List criteria","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","description":"Comma-separated criterion IDs to include"},"required":false,"description":"Comma-separated criterion IDs to include","name":"ids","in":"query"},{"schema":{"type":"string","enum":["true","false"],"description":"When true and ids is set, also include dependency ancestors"},"required":false,"description":"When true and ids is set, also include dependency ancestors","name":"ancestors","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CriteriaResponse"}}}}}}},"post":{"tags":["Criteria"],"summary":"Create criterion","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCriteriaInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"409":{"description":"Criterion already exists"}}}},"/api/v1/criteria/mdp":{"get":{"tags":["Criteria"],"summary":"Compute MDP transitions","parameters":[{"schema":{"type":"string"},"required":false,"name":"criteria","in":"query"},{"schema":{"type":"string"},"required":false,"name":"features","in":"query"},{"schema":{"type":"string"},"required":false,"name":"since","in":"query"},{"schema":{"type":"string"},"required":false,"name":"worker","in":"query"},{"schema":{"type":"string"},"required":false,"name":"taskPromptId","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":{}}}}}}}},"/api/v1/criteria/graph":{"get":{"tags":["Criteria"],"summary":"Get criteria DAG","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaGraph"}}}}}}},"/api/v1/criteria/{id}":{"get":{"tags":["Criteria"],"summary":"Get criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"404":{"description":"Criterion not found"}}},"put":{"tags":["Criteria"],"summary":"Update criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCriteriaInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CriteriaResponse"}}}},"400":{"description":"Invalid dependency reference or self-reference"},"404":{"description":"Criterion not found"}}},"delete":{"tags":["Criteria"],"summary":"Soft-delete criterion","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Criterion not found"},"409":{"description":"Criterion has dependents"}}}},"/api/v1/prompt-features/generate-prompt":{"post":{"tags":["Prompt Features"],"summary":"Generate prompt feature from behavior","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"behavior":{"type":"string"},"currentId":{"type":"string"}},"required":["behavior"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"]}}}},"400":{"description":"Empty behavior string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features/seed":{"post":{"tags":["Prompt Features"],"summary":"Seed prompt features in bulk","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}},"required":["features"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"seeded":{"type":"number"},"errors":{"type":"array","items":{"type":"string"}}},"required":["seeded","errors"]}}}}}}},"/api/v1/prompt-features/extract-from-text":{"post":{"tags":["Prompt Features"],"summary":"Extract features from text","parameters":[{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","example":"00000000-0000-0000-0000-000000000000"},"required":false,"description":"Optional project scope. When provided, a slug/id is resolved within that project (slugs may repeat across projects); when omitted, resolution falls back to a legacy global lookup for backward compatibility.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"text":{"type":"string"},"model":{"type":"string"},"type":{"type":"string","enum":["select","agents.md"]}},"required":["text"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["features","cached"]}}}},"400":{"description":"Empty text string"},"503":{"description":"LLM not configured"}}}},"/api/v1/prompt-features":{"get":{"tags":["Prompt Features"],"summary":"List features","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","enum":["select","agents.md"]},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}}}}},"post":{"tags":["Prompt Features"],"summary":"Create feature","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePromptFeatureInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"409":{"description":"Feature already exists"}}}},"/api/v1/prompt-features/{id}":{"get":{"tags":["Prompt Features"],"summary":"Get feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"404":{"description":"Feature not found"}}},"put":{"tags":["Prompt Features"],"summary":"Update feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PromptFeatureResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Feature not found"}}},"delete":{"tags":["Prompt Features"],"summary":"Soft-delete feature","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"deleted":{"type":"boolean"}},"required":["id","deleted"]}}}},"404":{"description":"Feature not found"}}}},"/api/v1/task-prompts/generate":{"post":{"tags":["Task Prompts"],"summary":"Generate task prompts","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"description":{"type":"string"},"existingPrompt":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"tasks":{"type":"array","items":{"type":"string"}}},"required":["tasks"]}}}},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts":{"get":{"tags":["Task Prompts"],"summary":"List task prompts","parameters":[{"schema":{"type":["number","null"]},"required":false,"name":"limit","in":"query"},{"schema":{"type":["number","null"]},"required":false,"name":"offset","in":"query"},{"schema":{"type":"string"},"required":false,"name":"search","in":"query"},{"schema":{"$ref":"#/components/schemas/PromptType"},"required":false,"name":"type","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/TaskPromptResponse"}},"total":{"type":"number"},"limit":{"type":"number"},"offset":{"type":"number"}},"required":["items","total","limit","offset"]}}}}}},"post":{"tags":["Task Prompts"],"summary":"Create or find task prompt","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTaskPromptInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Empty text string"}}}},"/api/v1/task-prompts/{id}":{"get":{"tags":["Task Prompts"],"summary":"Get task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"404":{"description":"Task prompt not found"}}},"delete":{"tags":["Task Prompts"],"summary":"Soft-delete task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}},"required":["deleted"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/content":{"get":{"tags":["Task Prompts"],"summary":"Get resolved task prompt content","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"}},"required":["id","text"]}}}},"404":{"description":"Task prompt not found"}}}},"/api/v1/task-prompts/{id}/extract-features":{"post":{"tags":["Task Prompts"],"summary":"Extract features from task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"force","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"model":{"type":"string"}}}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"taskPromptId":{"type":"string"},"features":{"type":"array","items":{"$ref":"#/components/schemas/PromptFeatureResult"}},"featuresExtractedAt":{"type":["string","null"],"format":"date-time"},"suggestedFeatures":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedPromptFeature"}},"cached":{"type":"boolean"}},"required":["taskPromptId","features","cached"]}}}},"404":{"description":"Task prompt not found"},"503":{"description":"LLM not configured"}}}},"/api/v1/task-prompts/{id}/features/{featureId}":{"patch":{"tags":["Task Prompts"],"summary":"Toggle feature flag on task prompt","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"featureId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchTaskPromptFeatureInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskPromptResponse"}}}},"400":{"description":"Invalid detected value"},"404":{"description":"Task prompt or feature not found"}}}},"/api/v1/reports":{"post":{"tags":["Reports"],"summary":"Create report","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"400":{"description":"Invalid input"},"404":{"description":"Run or template not found"}}},"get":{"tags":["Reports"],"summary":"List reports","parameters":[{"schema":{"type":"string"},"required":false,"name":"requestId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}}}}},"/api/v1/reports/bulk-create":{"post":{"tags":["Reports"],"summary":"Bulk create reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkCreateReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/bulk-status":{"post":{"tags":["Reports"],"summary":"Bulk get report statuses","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportStatusInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/bulk-summary":{"post":{"tags":["Reports"],"summary":"Bulk get report summary per run","description":"Returns aggregated report status counts per run. Only the latest report per template is counted (re-triggers are deduplicated).","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkReportSummaryResponse"}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}":{"get":{"tags":["Reports"],"summary":"Get report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportResponse"}}}},"404":{"description":"Report not found"}}}},"/api/v1/reports/{id}/logs":{"get":{"tags":["Reports"],"summary":"Stream report logs (SSE)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Server-sent event stream of log entries"},"404":{"description":"Report not found"}}}},"/api/v1/reports/trigger":{"post":{"tags":["Reports"],"summary":"Trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"triggered":{"type":"number"},"reports":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}},"required":["triggered","reports"]}}}},"400":{"description":"Invalid input"},"404":{"description":"Run not found"}}}},"/api/v1/reports/bulk-trigger":{"post":{"tags":["Reports"],"summary":"Bulk trigger reports","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkTriggerReportsInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{},"additionalProperties":{}}}}}},"400":{"description":"Invalid input"}}}},"/api/v1/reports/{id}/insights":{"get":{"tags":["Reports"],"summary":"Get insights for report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/InsightResponse"},{"type":"object","properties":{"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}}}]}}}}},"404":{"description":"Report not found"}}},"post":{"tags":["Reports"],"summary":"Link insight to report","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"isNew":{"type":"boolean"}},"required":["insightId"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"insightId":{"type":"string"},"referencedAt":{"type":["string","null"],"format":"date-time"},"isNew":{"type":"boolean"}},"required":["insightId","referencedAt","isNew"]}}}},"404":{"description":"Report or insight not found"},"409":{"description":"Insight already referenced by this report"}}}},"/api/v1/profiles":{"post":{"tags":["Profiles"],"summary":"Create a new profile","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateProfileInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"get":{"tags":["Profiles"],"summary":"List profiles","parameters":[{"schema":{"type":"string"},"required":false,"name":"workerType","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}":{"get":{"tags":["Profiles"],"summary":"Get profile with latest version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileWithVersionResponse"}}}}}},"post":{"tags":["Profiles"],"summary":"Create new profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"workerType":{"type":"string"},"model":{"type":"string"},"reasoningEffort":{"type":"string"},"agentVersion":{"type":"string"},"mcpServers":{"type":"array","items":{"type":"string"}},"skillRevisions":{"type":"array","items":{"type":"string"}},"extensions":{"type":"array","items":{"type":"string"}}},"required":["workerType","model"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}},"put":{"tags":["Profiles"],"summary":"Update profile identity","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileIdentity"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileResponse"}}}}}},"delete":{"tags":["Profiles"],"summary":"Delete profile","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/profiles/{profileId}/versions":{"get":{"tags":["Profiles"],"summary":"List profile versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}}},"/api/v1/profiles/{profileId}/versions/{version}":{"get":{"tags":["Profiles"],"summary":"Get profile version","parameters":[{"schema":{"type":"string"},"required":true,"name":"profileId","in":"path"},{"schema":{"type":["number","null"]},"required":false,"name":"version","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProfileVersionResponse"}}}}}}},"/api/v1/report-templates/default-system-prompt":{"get":{"tags":["Report Templates"],"summary":"Get default system prompt","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"content":{"type":"string"}},"required":["content"]}}}}}}},"/api/v1/report-templates/available-models":{"get":{"tags":["Report Templates"],"summary":"List models available for report generation","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"modelId":{"type":"string"}},"required":["modelId"]}}}}}}}},"/api/v1/report-templates":{"get":{"tags":["Report Templates"],"summary":"List report templates","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}}}}},"post":{"tags":["Report Templates"],"summary":"Create report template","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportTemplateInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"409":{"description":"Report template already exists"}}}},"/api/v1/report-templates/{id}":{"get":{"tags":["Report Templates"],"summary":"Get report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"put":{"tags":["Report Templates"],"summary":"Update report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateReportTemplateInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReportTemplateResponse"}}}},"404":{"description":"Report template not found"}}},"delete":{"tags":["Report Templates"],"summary":"Delete report template","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Report template not found"}}}},"/api/v1/agents":{"get":{"tags":["Agents"],"summary":"List agents","parameters":[{"schema":{"type":"string"},"required":false,"name":"modelProvider","in":"query"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentResponse"}}}}}}},"post":{"tags":["Agents"],"summary":"Create or update agent (upsert)","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAgentInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"409":{"description":"Restored agent versions conflict with an active queue owner"}}}},"/api/v1/agents/{id}":{"get":{"tags":["Agents"],"summary":"Get agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"includeDeleted","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"404":{"description":"Agent not found"}}},"put":{"tags":["Agents"],"summary":"Update agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAgentInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentResponse"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"}}},"delete":{"tags":["Agents"],"summary":"Delete agent","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Agent not found"}}}},"/api/v1/agents/{id}/versions":{"get":{"tags":["Agents"],"summary":"List agent versions","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AgentVersion"}}}}},"404":{"description":"Agent not found"}}},"post":{"tags":["Agents"],"summary":"Register agent version (upsert)","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterAgentVersionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Validation error"},"404":{"description":"Agent not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent registration did not stabilize"}}}},"/api/v1/agents/{id}/versions/{agentVersion}":{"patch":{"tags":["Agents"],"summary":"Patch agent version","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"agentVersion","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchAgentVersionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVersion"}}}},"400":{"description":"Invalid status value"},"404":{"description":"Agent or version not found"},"409":{"description":"Queue is assigned to another agent"},"503":{"description":"Concurrent activation did not stabilize"}}}},"/api/v1/models":{"get":{"tags":["Models"],"summary":"List models","parameters":[{"schema":{"type":"string"},"required":false,"name":"agentId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"provider","in":"query"},{"schema":{"type":"string","enum":["active","disappeared"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ModelResponse"}}}}}}}},"/api/v1/models/{id}":{"get":{"tags":["Models"],"summary":"Get model","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelResponse"}}}}}}},"/api/v1/models/sync":{"post":{"tags":["Models"],"summary":"Sync models from provider","requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"agentId":{"type":"string"},"provider":{"type":"string"},"models":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"providerAvailableFrom":{"type":"string"},"providerEndOfLife":{"type":"string"},"metadata":{"type":"object","additionalProperties":{}},"capabilities":{"type":"object","properties":{"reasoningEffort":{"type":"array","items":{"type":"string"}},"toolCalls":{"type":"boolean"},"vision":{"type":"boolean"},"streaming":{"type":"boolean"},"adaptiveThinking":{"type":"boolean"},"maxThinkingBudget":{"type":"number"}}}},"required":["id"]}},"scannedAt":{"type":"string"}},"required":["agentId","provider","models","scannedAt"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"added":{"type":"array","items":{"type":"string"}},"removed":{"type":"array","items":{"type":"string"}},"unchanged":{"type":"array","items":{"type":"string"}}},"required":["added","removed","unchanged"]}}}}}}},"/api/v1/mcp/servers":{"get":{"tags":["MCP Servers"],"summary":"List MCP servers","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"post":{"tags":["MCP Servers"],"summary":"Create MCP server","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"_id":{"type":"string","pattern":"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"},"name":{"type":"string"},"type":{"type":"string","enum":["sse","http","stdio"]},"url":{"type":"string"},"command":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"env":{"type":"object","additionalProperties":{"type":"string"}},"headers":{"type":"array","items":{"$ref":"#/components/schemas/McpServerHeader"}},"sessionMode":{"type":"string","enum":["stateful","stateless"]},"version":{"type":"string"},"description":{"type":"string"}},"required":["_id","name","type"]}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}}},"/api/v1/mcp/servers/{id}":{"get":{"tags":["MCP Servers"],"summary":"Get MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"put":{"tags":["MCP Servers"],"summary":"Update MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMcpServerInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpServerResponse"}}}}}},"delete":{"tags":["MCP Servers"],"summary":"Delete MCP server","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/skills":{"get":{"tags":["Skills"],"summary":"List all skills","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"post":{"tags":["Skills"],"summary":"Create or import a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSkillInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}}}}},"/api/v1/skills/search":{"get":{"tags":["Skills"],"summary":"Search skills (internal + external)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/search/external":{"get":{"tags":["Skills"],"summary":"Search external skills registry","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project."},"required":false,"description":"Optional project scope. When provided, external results are de-duplicated only against skills already installed in that project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/skills/discover":{"get":{"tags":["Skills"],"summary":"Discover skills in a GitHub repository","parameters":[{"schema":{"type":"string"},"required":true,"name":"source","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillDiscoveryResult"}}}}},"400":{"description":"Missing or malformed source parameter"},"404":{"description":"Repository not found"},"502":{"description":"GitHub API error"}}}},"/api/v1/skills/{id}(*)/revisions":{"get":{"tags":["Skills"],"summary":"List skill revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}}},"404":{"description":"Skill not found"}}}},"/api/v1/skills/{id}(*)":{"get":{"tags":["Skills"],"summary":"Get skill by slug","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillResponse"}}}},"404":{"description":"Skill not found"}}},"delete":{"tags":["Skills"],"summary":"Soft-delete a skill","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success"},"404":{"description":"Skill not found"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)/archive":{"get":{"tags":["Skill Revisions"],"summary":"Download skill revision archive","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Skill revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/skill-revisions/by-ref/{ref}(*)":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ref","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skill-revisions/{id}":{"get":{"tags":["Skill Revisions"],"summary":"Get skill revision by ID","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill revision not found"}}}},"/api/v1/skills/{id}(*)/resolve":{"post":{"tags":["Skills"],"summary":"Trigger skill resolution","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SkillRevisionResponse"}}}},"404":{"description":"Skill not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebases":{"get":{"tags":["Codebases"],"summary":"List all codebases","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseResponse"}}}}}}},"post":{"tags":["Codebases"],"summary":"Create a codebase (archive codebases require the archive file)","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input or missing archive"}}}},"/api/v1/codebases/{id}":{"get":{"tags":["Codebases"],"summary":"Get a codebase","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"patch":{"tags":["Codebases"],"summary":"Update a codebase","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCodebaseInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"delete":{"tags":["Codebases"],"summary":"Delete a codebase","responses":{"204":{"description":"Success"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebases/{id}/revisions":{"get":{"tags":["Codebases"],"summary":"List codebase revisions","parameters":[{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}}},"404":{"description":"Codebase not found"}}},"post":{"tags":["Codebases"],"summary":"Resolve a new git codebase revision","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolveCodebaseRevisionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"400":{"description":"Not a git codebase"},"404":{"description":"Codebase not found"},"502":{"description":"GitHub resolution failed"}}}},"/api/v1/codebases/{id}/upload":{"post":{"tags":["Codebases"],"summary":"Upload a codebase archive as a new revision","responses":{"201":{"description":"Success"},"400":{"description":"Missing archive or not an archive codebase"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebase-revisions/{id}/archive":{"get":{"tags":["Codebase Revisions"],"summary":"Download codebase revision archive","responses":{"200":{"description":"Binary tar.gz archive"},"404":{"description":"Revision or archive not found"},"500":{"description":"Blob storage error"}}}},"/api/v1/codebase-revisions/{id}":{"get":{"tags":["Codebase Revisions"],"summary":"Get codebase revision by id","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/extensions":{"get":{"tags":["Extensions"],"summary":"List all extensions","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"post":{"tags":["Extensions"],"summary":"Create or import an extension","parameters":[{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateExtensionInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}}},"/api/v1/extensions/search":{"get":{"tags":["Extensions"],"summary":"Search extensions (internal + marketplace)","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionSearchResult"}}}}},"400":{"description":"Missing query parameter"}}}},"/api/v1/extensions/{id}":{"get":{"tags":["Extensions"],"summary":"Get extension by ID","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"put":{"tags":["Extensions"],"summary":"Update extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateExtensionInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExtensionResponse"}}}}}},"delete":{"tags":["Extensions"],"summary":"Delete extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}}}}},"/api/v1/extensions/{id}/versions":{"get":{"tags":["Extensions"],"summary":"List available versions for an extension","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":false,"name":"preRelease","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ExtensionVersionInfo"}}}}}}}},"/api/v1/insights":{"get":{"tags":["Insights"],"summary":"List insights","parameters":[{"schema":{"type":"string"},"required":false,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"post":{"tags":["Insights"],"summary":"Create insight","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInsightInput"}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}}}}},"/api/v1/insights/search":{"get":{"tags":["Insights"],"summary":"Search insights","parameters":[{"schema":{"type":"string"},"required":true,"name":"q","in":"query"},{"schema":{"type":"string"},"required":false,"name":"blocked","in":"query"},{"schema":{"type":"string","minLength":1,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","example":"00000000-0000-0000-0000-000000000000"},"required":true,"description":"Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.","name":"projectId","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InsightResponse"}}}}}}}},"/api/v1/insights/{id}":{"get":{"tags":["Insights"],"summary":"Get insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"put":{"tags":["Insights"],"summary":"Update insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateInsightInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}},"delete":{"tags":["Insights"],"summary":"Delete insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"204":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/upvote":{"post":{"tags":["Insights"],"summary":"Upvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/downvote":{"post":{"tags":["Insights"],"summary":"Downvote insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/block":{"post":{"tags":["Insights"],"summary":"Block insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/unblock":{"post":{"tags":["Insights"],"summary":"Unblock insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InsightResponse"}}}},"404":{"description":"Insight not found"}}}},"/api/v1/insights/{id}/reports":{"get":{"tags":["Insights"],"summary":"Get reports referencing insight","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ReportResponse"}}}}},"404":{"description":"Insight not found"}}}},"/api/v1/feature-flags":{"get":{"tags":["Feature Flags"],"summary":"List feature flags","responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"/api/v1/feature-flags/{key}":{"put":{"tags":["Feature Flags"],"summary":"Update feature flag","parameters":[{"schema":{"type":"string"},"required":true,"name":"key","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFeatureFlagInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FeatureFlagResponse"}}}}}}}},"webhooks":{}} \ No newline at end of file