diff --git a/.env.example b/.env.example index 916bc1e40..af5d20c35 100644 --- a/.env.example +++ b/.env.example @@ -121,15 +121,19 @@ FEEDBACK_MAX_CRITERIA=1 FEEDBACK_DESCENDANT_GUARD=true # --------------------------------------------------------------------------- -# API Authentication (Microsoft Entra ID) — identity-only, non-breaking +# Authentication and project-membership RBAC (Microsoft Entra ID) # --------------------------------------------------------------------------- -# 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. +# Enabled by default; missing IdP configuration fails API startup. +# Explicit false disables BOTH API auth/RBAC and Portal sign-in, exposing normal +# application APIs to network callers. Use only in isolated trusted test/local envs. +# Recreate API and Portal together; users/memberships/audit history are preserved. +# SCOPE_AUTH_ENABLED=true +# Clients explicitly enroll through POST /api/v1/users/me, then use the unchanged +# IdP bearer on ordinary requests. GET /users/me is read-only and never enrolls. +# Identity snapshots are cached; canonical membership checks remain authoritative. # See ENV_VARIABLES.md → "API Authentication" for details. -# Identity provider. Set to `entra` to enable token verification. +# Required identity provider when SCOPE_AUTH_ENABLED is enabled (the default). # AUTH_PROVIDER=entra # OIDC authority for JWKS discovery + issuer validation (multi-tenant example). @@ -158,11 +162,9 @@ FEEDBACK_DESCENDANT_GUARD=true # 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= +# First sign in to enroll, then use the operator bootstrap-admin command. +# Login never promotes an admin. AUTH_BOOTSTRAP_ADMINS/TENANTS are retired; +# nonempty values prevent authenticated API startup. See docs/architecture/auth-rbac.md. # Fixed active-user cache lifetime, in seconds (positive integer, default 300). # Cache hits do not extend expiry; Redis outages fall back to MongoDB. diff --git a/.env.local.example b/.env.local.example index e412458e0..6a81dad0b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -57,10 +57,10 @@ LLM_MODEL= # 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 +# Trust the emulator's local CA; never disable TLS or signature verification. +# The Compose wrapper generates/trusts the development CA and mounts it for the +# API. Native host clients can use NODE_EXTRA_CA_CERTS=$PWD/.certs/rootCA.pem. +# The emulator advertises issuer + JWKS under # https://localhost:8443//v2.0. # # Seeded identities (see the entra-local docs): @@ -70,6 +70,10 @@ LLM_MODEL= # user alice@entralocal.dev -> oid aaaaaaaa-0000-0000-0000-000000000001 (bootstrap candidate below) # user bob@entralocal.dev -> oid aaaaaaaa-0000-0000-0000-000000000002 # --------------------------------------------------------------------------- +# Auth and RBAC default on. Explicit false permits anonymous application access +# without the emulator; use only in network-isolated trusted local/test envs. +# Set the same mode for API and Portal and recreate both; do not use for recovery. +# SCOPE_AUTH_ENABLED=true # AUTH_PROVIDER=entra # OIDC authority — the emulator issuer base for the seeded tenant. In a worktree @@ -102,13 +106,10 @@ LLM_MODEL= # 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 +# Alice and Bob initially enroll as ordinary users, using genuine emulator tokens. +# Promote an existing exact identity with the operator bootstrap-admin command; +# see docs/architecture/auth-rbac.md#durable-changes-and-operator-runbooks. +# Login never grants or restores admin. Remove retired AUTH_BOOTSTRAP_* settings. # Fixed active-user Redis cache lifetime; normal requests never update lastLoginAt. # Redis keys are namespaced by MONGO_DATABASE and verified provider/tid/oid. diff --git a/ENV_VARIABLES.md b/ENV_VARIABLES.md index bc66a4316..b4d04f9da 100644 --- a/ENV_VARIABLES.md +++ b/ENV_VARIABLES.md @@ -495,67 +495,69 @@ 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 +> resolves an active Scope user. Project-membership RBAC protects application +> routes when authentication is enabled. After a public deployment-mode check, +> the Portal's first identity request after a redirect callback 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) - -Portal auth is a **feature-flagged capability** with **three independent, -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. - -> **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 -calls. MSAL is never initialized. - -**Why local is build-time but int/prod are runtime.** The production Portal image -is **built once and promoted** integration→production (the overlay `images.yaml` -files pin the same tag; see `.github/workflows/promote.yml`). A build-time -`VITE_*` flag is baked into that single image and therefore **cannot differ** -between integration and production. So int/prod are governed at **runtime** (an -env var read at container start), while local dev — which runs `vite dev`, not the -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`; 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 -default (**enabled**). - -**How it works at runtime (int/prod).** `SCOPE_AUTH_ENABLED` is read by -`apps/portal/docker-entrypoint.sh`, which writes `authEnabled` into `/config.js` -(→ `window.__SCOPE_CONFIG__.authEnabled`) when the container starts. The app reads -that value at load. This is the same mechanism already used for -`SCOPE_DOCS_BASE_URL`. - -**Resolution precedence** (in `apps/portal/src/lib/auth/authConfig.ts`): the -runtime `window.__SCOPE_CONFIG__.authEnabled` (int/prod) wins whenever present; -otherwise, in local dev, `VITE_AUTH_ENABLED_LOCAL` applies; otherwise it defaults -to **enabled**. The dev `public/config.js` intentionally ships **no** `authEnabled` -so local always falls through to the Vite flag. - -- **Local dev:** set `VITE_AUTH_ENABLED_LOCAL=false` in `docker-compose.dev.yml` - (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 when an anonymous rollout is intended. - No image rebuild is needed — it takes effect on the next - pod start. +### SCOPE_AUTH_ENABLED — combined authentication and RBAC toggle + +**Default:** `true`. Set the **same value on the API and Portal** and restart or +recreate both services. This is deployment configuration, not a per-user switch. +Only `false`/`0`/`no`/`off` disable protection, ignoring case and surrounding +whitespace; missing, empty or invalid values stay enabled. Enabled mode requires +valid IdP configuration and preserves verified enrollment and project membership. + +> **Security warning:** explicit off mode makes normal application APIs and +> project content accessible to every network caller. Use it only in trusted +> local/test environments with appropriate network isolation. It is not an +> authority-outage fallback or an operator lockout-recovery procedure. + +With `SCOPE_AUTH_ENABLED=false`, the API starts without IdP configuration, and the +Portal skips MSAL/sign-in, enrollment and bearer attachment. Ordinary application +workflows, catalogs and project create/edit/delete/restore work anonymously. +There is no fabricated admin user or membership; durable project operations use +the existing anonymous actor. Project scoping, project existence and parent/ +reference checks still apply. `/users/me` is unavailable (`401`), and user/ +membership administration returns `503 authentication_disabled`; `/access` is +unavailable. Report generation remains independently disabled in both modes. + +Existing users, roles, memberships and audit records are preserved. Re-enabling +auth restores membership enforcement; anonymously created projects require +explicit membership assignment. Use the audited operator procedure for lockout +recovery instead of switching protection off. + +Compose passes `SCOPE_AUTH_ENABLED` to both services. The older +`VITE_AUTH_ENABLED_LOCAL` remains a Compose fallback **only when the canonical +variable is unset**. Even an explicitly empty canonical value takes precedence +and stays enabled. Native `vite dev` also honors a shell `SCOPE_AUTH_ENABLED` +before the legacy local flag. Production Portal images read the setting at +container startup into `/config.js`, so the same image can be promoted between +environments; built bundles do not use the local-only flag. + +Before mounting the application, the Portal checks the public, no-store +`GET /api/v1/version` response's `authEnabled` boolean. It polls every 10 seconds +with bounded requests; unavailable, missing or mismatched configuration blocks +the app rather than silently choosing anonymous mode. Changing browser settings +cannot disable API protection. CLI project selection also discovers this mode: +auth-on requires membership, while explicit off permits anonymous selection. + +For an already configured worktree, start only core services without workers or +the emulator (the wrapper enables the native Mongo profile): + +```bash +SCOPE_AUTH_ENABLED=false NODE_EXTRA_CA_CERTS= scripts/dev-compose.sh \ + -f docker-compose.yml -f docker-compose.dev.yml --profile portal \ + up -d --build mongodb redis azurite db-migrate api portal +``` + +Do not include `--profile auth` for this mode. Keep published ports restricted to +trusted interfaces; use your isolated project's existing loopback override and +`-p` name where applicable. To restore authenticated mode, set +`SCOPE_AUTH_ENABLED=true`, supply the IdP settings below and recreate API/Portal +with the auth profile when using the emulator. ### Local dev setup (entra-local) @@ -589,6 +591,12 @@ that starts the Portal (e.g. `pnpm docker:dev:copilot`, `pnpm docker:dev:portal` 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`). + It also enables `given_name`, `family_name`, `email` and `preferred_username` + optional access-token claims on the seeded Sample Full-stack API registration, + preserving existing optional claims. This supplies recognizable names and + sign-in aliases to Scope at explicit enrollment without weakening signature, + TLS, identity matching or email-verification requirements. Existing users + refresh their profile labels on their next explicit sign-in, not on GET. **Prerequisites:** [mkcert](https://github.com/FiloSottile/mkcert) and `openssl` must be installed (`brew install mkcert nss` on macOS, with `openssl` available @@ -910,7 +918,8 @@ 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 +profile and `lastLoginAt`, always inserting new identities as ordinary users. +Login never changes role or disabled state. 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 @@ -923,18 +932,21 @@ Clients also use no-store; the enrollment POST has side effects and must never b 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 +Auth and RBAC default to enabled: missing or partial IdP configuration fails +startup, and missing tokens on protected routes return `401`. Only explicit +`SCOPE_AUTH_ENABLED=false` permits anonymous application access as described +above; this deliberately disables bearer verification and membership enforcement, +not merely the Portal sign-in screen. 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). +Project-membership authorization independently checks canonical users, +memberships/projects and durable operation markers after identity resolution. +See [Authentication & RBAC](docs/architecture/auth-rbac.md#project-membership-mvp) +for the local implementation and production release gates. ### AUTH_USER_CACHE_TTL_SECONDS **Default:** `300` (only when unset) @@ -942,10 +954,9 @@ Full route RBAC/ownership enforcement is **not** part of this milestone. See **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 +Blank, zero, negative, fractional, nonnumeric, or unsafe values fail startup when +authentication is enabled. Explicit off mode ignores unused IdP/cache settings. +This variable alone does not configure an IdP. 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`. @@ -972,18 +983,20 @@ caching. Connection/command waits are bounded, with no offline command queuing/r 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. +Identity snapshot metadata can remain stale until expiry, but it is not the +authorization authority. Protected requests/capabilities recheck canonical access +within a 20-second decision budget, and streams expire/revalidate their leases. +Do not tune this identity TTL to claim authorization freshness; the <=60-second +total production guarantee still requires Cosmos topology/visibility proof. ### 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. +Selects the identity-provider implementation. With auth enabled (the default), +set to `entra` with the required authority/audience settings; missing or partial +configuration fails startup rather than disabling verification. Explicit +`SCOPE_AUTH_ENABLED=false` ignores these settings. ### AUTH_AUTHORITY **Type:** URL string — **Required when `AUTH_PROVIDER` is set** @@ -1064,31 +1077,28 @@ 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 +### AUTH_BOOTSTRAP_ADMINS / AUTH_BOOTSTRAP_TENANTS (retired) -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. +Remove both settings from deployment configuration before upgrading an +authenticated API. A nonempty value now fails startup with an actionable error; +empty/unset values are accepted. Neither setting can grant or restore admin. +New users must genuinely sign in first, then an operator promotes an exact +existing identity using `bootstrap-admin`. There is no first-user-wins, +startup seeding, email matching or synthetic-principal mode. + +### AUTH_OPERATOR_MONGO_URI +**Default:** (not set) +**Type:** MongoDB connection URI +**Scope:** Operator bootstrap/repair and reviewed legacy-membership commands only + +Provide through secure operator configuration, never tracked files or shell +history. The commands additionally require an explicit database and exact +identity/Scope UUID or reviewed manifest. This does not configure the API's +normal connection, enable a dev principal, turn auth off or grant content access. +The operator utility exposes `bootstrap-admin`, `set-user-access`, +`inspect-operation` and `repair-operation`; mutations default to dry run and +require `--apply`. Recovery is not exposed in the Portal, REST API or Scope CLI. +See the [operator runbooks](docs/architecture/auth-rbac.md#durable-changes-and-operator-runbooks). > **Future — Graph profile enrichment.** `email`/`displayName` are read directly > from the verified token claims during explicit login today (no Microsoft Graph call, no client diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap index 49b9f80b3..f05f0e4b4 100644 --- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap +++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap @@ -812,24 +812,6 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "type": "object", }, - "CreateProjectInput": { - "properties": { - "creator": { - "type": "string", - }, - "description": { - "type": "string", - }, - "name": { - "minLength": 1, - "type": "string", - }, - }, - "required": [ - "name", - ], - "type": "object", - }, "CreatePromptFeatureInput": { "properties": { "id": { @@ -2173,6 +2155,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "_id": { "type": "string", }, + "appliedOperationIds": { + "items": { + "type": "string", + }, + "type": "array", + }, "createdAt": { "format": "date-time", "type": [ @@ -2193,9 +2181,16 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": { "type": "string", }, + "lastOperationId": { + "type": "string", + }, "name": { "type": "string", }, + "revision": { + "minimum": 0, + "type": "integer", + }, "updatedAt": { "format": "date-time", "type": [ @@ -3849,18 +3844,6 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "type": "object", }, - "UpdateProjectInput": { - "properties": { - "description": { - "type": "string", - }, - "name": { - "minLength": 1, - "type": "string", - }, - }, - "type": "object", - }, "UpdatePromptFeatureInput": { "properties": { "prompt": { @@ -3926,6 +3909,70 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "UserMeResponse": { "properties": { + "capabilities": { + "additionalProperties": false, + "properties": { + "memberships": { + "items": { + "additionalProperties": false, + "properties": { + "permissions": { + "items": { + "enum": [ + "content:read", + "content:write", + "content:delete", + "settings:write", + "members:write", + ], + "type": "string", + }, + "type": "array", + }, + "projectId": { + "minLength": 1, + "type": "string", + }, + "revision": { + "exclusiveMinimum": 0, + "type": "integer", + }, + "role": { + "enum": [ + "reader", + "contributor", + "admin", + ], + "type": "string", + }, + }, + "required": [ + "projectId", + "role", + "permissions", + "revision", + ], + "type": "object", + }, + "type": "array", + }, + "platformAdmin": { + "type": "boolean", + }, + "reportsEnabled": { + "enum": [ + false, + ], + "type": "boolean", + }, + }, + "required": [ + "platformAdmin", + "reportsEnabled", + "memberships", + ], + "type": "object", + }, "displayName": { "type": "string", }, @@ -3947,6 +3994,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "required": [ "id", + "capabilities", ], "type": "object", }, @@ -4023,6 +4071,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [], "summary": "API metadata", "tags": [ "System", @@ -4046,6 +4095,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List accounts", "tags": [ "Accounts", @@ -4073,6 +4127,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Account created", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create account", "tags": [ "Accounts", @@ -4111,6 +4170,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Account deleted", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete account", "tags": [ "Accounts", @@ -4139,6 +4203,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get account", "tags": [ "Accounts", @@ -4176,33 +4245,39 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Account updated", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update account", "tags": [ "Accounts", ], }, }, - "/api/v1/agents": { + "/api/v1/admin/platform-admins": { "get": { "parameters": [ { "in": "query", - "name": "modelProvider", + "name": "cursor", "required": false, "schema": { + "maxLength": 200, + "minLength": 1, "type": "string", }, }, { "in": "query", - "name": "includeDeleted", + "name": "limit", "required": false, "schema": { - "enum": [ - "true", - "false", - ], - "type": "string", + "default": 20, + "maximum": 50, + "minimum": 1, + "type": "integer", }, }, ], @@ -4211,154 +4286,331 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/AgentResponse", + "properties": { + "items": { + "items": { + "properties": { + "alias": { + "type": "string", + }, + "disabled": { + "type": "boolean", + }, + "displayName": { + "type": "string", + }, + "email": { + "type": "string", + }, + "id": { + "type": "string", + }, + "operationId": { + "type": "string", + }, + "pending": { + "type": "boolean", + }, + "revision": { + "minimum": 0, + "type": "integer", + }, + "role": { + "enum": [ + "admin", + ], + "type": "string", + }, + }, + "required": [ + "id", + "role", + "disabled", + "revision", + "pending", + ], + "type": "object", + }, + "type": "array", + }, + "nextCursor": { + "type": "string", + }, }, - "type": "array", + "required": [ + "items", + ], + "type": "object", }, }, }, "description": "Success", }, }, - "summary": "List agents", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List platform administrators, including disabled accounts and pending changes", "tags": [ - "Agents", + "Access", ], }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateAgentInput", - }, + }, + "/api/v1/admin/users": { + "get": { + "parameters": [ + { + "in": "query", + "name": "userId", + "required": false, + "schema": { + "type": "string", }, }, - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentResponse", - }, - }, + { + "in": "query", + "name": "idp", + "required": false, + "schema": { + "type": "string", }, - "description": "Success", - }, - "400": { - "description": "Validation error", }, - "409": { - "description": "Restored agent versions conflict with an active queue owner", + { + "in": "query", + "name": "tenant", + "required": false, + "schema": { + "type": "string", + }, }, - }, - "summary": "Create or update agent (upsert)", - "tags": [ - "Agents", - ], - }, - }, - "/api/v1/agents/{id}": { - "delete": { - "parameters": [ { - "in": "path", - "name": "id", - "required": true, + "in": "query", + "name": "subject", + "required": false, "schema": { "type": "string", }, }, ], "responses": { - "204": { + "200": { "content": { "application/json": { "schema": { - "properties": { - "message": { - "type": "string", + "items": { + "properties": { + "alias": { + "type": "string", + }, + "disabled": { + "type": "boolean", + }, + "displayName": { + "type": "string", + }, + "email": { + "type": "string", + }, + "id": { + "type": "string", + }, + "revision": { + "minimum": 0, + "type": "integer", + }, + "role": { + "type": "string", + }, }, + "required": [ + "id", + "role", + "disabled", + "revision", + ], + "type": "object", }, - "required": [ - "message", - ], - "type": "object", + "type": "array", }, }, }, "description": "Success", }, - "404": { - "description": "Agent not found", - }, }, - "summary": "Delete agent", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Look up an exactly identified enrolled user", "tags": [ - "Agents", + "Access", ], }, + }, + "/api/v1/admin/users/search": { "get": { "parameters": [ { - "in": "path", - "name": "id", - "required": true, + "in": "query", + "name": "q", + "required": false, "schema": { + "default": "", + "maxLength": 100, "type": "string", }, }, { "in": "query", - "name": "includeDeleted", + "name": "cursor", "required": false, "schema": { - "enum": [ - "true", - "false", - ], + "maxLength": 200, + "minLength": 1, "type": "string", }, }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 50, + "minimum": 1, + "type": "integer", + }, + }, ], "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentResponse", + "properties": { + "items": { + "items": { + "properties": { + "alias": { + "type": "string", + }, + "disabled": { + "type": "boolean", + }, + "displayName": { + "type": "string", + }, + "email": { + "type": "string", + }, + "id": { + "type": "string", + }, + "revision": { + "minimum": 0, + "type": "integer", + }, + "role": { + "type": "string", + }, + }, + "required": [ + "id", + "role", + "disabled", + "revision", + ], + "type": "object", + }, + "type": "array", + }, + "nextCursor": { + "type": "string", + }, + }, + "required": [ + "items", + ], + "type": "object", }, }, }, "description": "Success", }, - "404": { - "description": "Agent not found", - }, }, - "summary": "Get agent", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Search enrolled platform users (platform administration)", "tags": [ - "Agents", + "Access", ], }, - "put": { - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", + }, + "/api/v1/admin/users/{id}/disable": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "revision": { + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "revision", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": {}, + }, }, + "description": "Success", + }, + }, + "security": [ + { + "bearerAuth": [], }, ], + "summary": "Disable an ordinary user", + "tags": [ + "Access", + ], + }, + }, + "/api/v1/admin/users/{id}/promote": { + "post": { "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateAgentInput", + "additionalProperties": false, + "properties": { + "revision": { + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "revision", + ], + "type": "object", }, }, }, @@ -4368,41 +4620,74 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentResponse", + "properties": { + "operationId": { + "type": "string", + }, + "revision": { + "exclusiveMinimum": 0, + "type": "integer", + }, + "state": { + "enum": [ + "completed", + "pending", + ], + "type": "string", + }, + }, + "required": [ + "operationId", + "state", + "revision", + ], + "type": "object", }, }, }, "description": "Success", }, - "400": { - "description": "Validation error", + "202": { + "description": "Promotion is pending; retry identical input or ask an operator to inspect/repair the same operation ID before claiming completion", }, "404": { - "description": "Agent not found", + "description": "Enrolled user not found", + }, + "409": { + "description": "Revision, role, enabled state or idempotency precondition conflict", }, }, - "summary": "Update agent", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Promote an enabled enrolled user to platform administrator", "tags": [ - "Agents", + "Access", ], }, }, - "/api/v1/agents/{id}/versions": { + "/api/v1/agents": { "get": { "parameters": [ { - "in": "path", - "name": "id", - "required": true, + "in": "query", + "name": "modelProvider", + "required": false, "schema": { "type": "string", }, }, { "in": "query", - "name": "status", + "name": "includeDeleted", "required": false, "schema": { + "enum": [ + "true", + "false", + ], "type": "string", }, }, @@ -4413,7 +4698,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/AgentVersion", + "$ref": "#/components/schemas/AgentResponse", }, "type": "array", }, @@ -4421,31 +4706,23 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "description": "Success", }, - "404": { - "description": "Agent not found", - }, }, - "summary": "List agent versions", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List agents", "tags": [ "Agents", ], }, "post": { - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - }, - }, - ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RegisterAgentVersionInput", + "$ref": "#/components/schemas/CreateAgentInput", }, }, }, @@ -4455,7 +4732,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentVersion", + "$ref": "#/components/schemas/AgentResponse", }, }, }, @@ -4464,24 +4741,23 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "400": { "description": "Validation error", }, - "404": { - "description": "Agent not found", - }, "409": { - "description": "Queue is assigned to another agent", - }, - "503": { - "description": "Concurrent registration did not stabilize", + "description": "Restored agent versions conflict with an active queue owner", }, }, - "summary": "Register agent version (upsert)", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Create or update agent (upsert)", "tags": [ "Agents", ], }, }, - "/api/v1/agents/{id}/versions/{agentVersion}": { - "patch": { + "/api/v1/agents/{id}": { + "delete": { "parameters": [ { "in": "path", @@ -4491,194 +4767,153 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "type": "string", }, }, - { - "in": "path", - "name": "agentVersion", - "required": true, - "schema": { - "type": "string", - }, - }, ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PatchAgentVersionInput", - }, - }, - }, - }, "responses": { - "200": { + "204": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentVersion", + "properties": { + "message": { + "type": "string", + }, + }, + "required": [ + "message", + ], + "type": "object", }, }, }, "description": "Success", }, - "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", + "description": "Agent not found", }, }, - "summary": "Patch agent version", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Delete agent", "tags": [ "Agents", ], }, - }, - "/api/v1/analysis": { "get": { - "description": "Aggregates pass@k / success@T metrics over a single project's done runs. Requires ?projectId=.", "parameters": [ { - "in": "query", - "name": "worker", - "required": false, + "in": "path", + "name": "id", + "required": true, "schema": { "type": "string", }, }, { "in": "query", - "name": "taskPromptId", + "name": "includeDeleted", "required": false, "schema": { + "enum": [ + "true", + "false", + ], "type": "string", }, }, - { - "in": "query", - "name": "criteria", - "required": false, - "schema": { - "type": "string", - }, - }, - { - "in": "query", - "name": "features", - "required": false, - "schema": { - "type": "string", + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentResponse", + }, + }, }, + "description": "Success", }, - { - "in": "query", - "name": "submissionId", - "required": false, - "schema": { - "type": "string", - }, + "404": { + "description": "Agent not found", }, + }, + "security": [ { - "in": "query", - "name": "k", - "required": false, - "schema": { - "type": "string", - }, + "bearerAuth": [], }, + ], + "summary": "Get agent", + "tags": [ + "Agents", + ], + }, + "put": { + "parameters": [ { - "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.", - "in": "query", - "name": "projectId", + "in": "path", + "name": "id", "required": true, "schema": { - "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", - "minLength": 1, "type": "string", }, }, ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": {}, - "description": "Analysis metrics", - "properties": {}, - "type": "object", - }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAgentInput", }, }, - "description": "Success", }, }, - "summary": "Compute pass@k / success@T metrics", - "tags": [ - "Requests", - ], - }, - }, - "/api/v1/codebase-revisions/{id}": { - "get": { "responses": { "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CodebaseRevisionResponse", + "$ref": "#/components/schemas/AgentResponse", }, }, }, "description": "Success", }, - "404": { - "description": "Revision not found", - }, - }, - "summary": "Get codebase revision by id", - "tags": [ - "Codebase Revisions", - ], - }, - }, - "/api/v1/codebase-revisions/{id}/archive": { - "get": { - "responses": { - "200": { - "description": "Binary tar.gz archive", + "400": { + "description": "Validation error", }, "404": { - "description": "Revision or archive not found", - }, - "500": { - "description": "Blob storage error", + "description": "Agent not found", }, }, - "summary": "Download codebase revision archive", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Update agent", "tags": [ - "Codebase Revisions", + "Agents", ], }, }, - "/api/v1/codebases": { + "/api/v1/agents/{id}/versions": { "get": { "parameters": [ { - "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.", - "in": "query", - "name": "projectId", + "in": "path", + "name": "id", "required": true, "schema": { - "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", - "minLength": 1, + "type": "string", + }, + }, + { + "in": "query", + "name": "status", + "required": false, + "schema": { "type": "string", }, }, @@ -4689,7 +4924,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/CodebaseResponse", + "$ref": "#/components/schemas/AgentVersion", }, "type": "array", }, @@ -4697,83 +4932,100 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "description": "Success", }, + "404": { + "description": "Agent not found", + }, }, - "summary": "List all codebases", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List agent versions", "tags": [ - "Codebases", + "Agents", ], }, "post": { "parameters": [ { - "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.", - "in": "query", - "name": "projectId", + "in": "path", + "name": "id", "required": true, "schema": { - "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", - "minLength": 1, "type": "string", }, }, ], - "responses": { - "201": { - "description": "Success", - }, - "400": { - "description": "Invalid input or missing archive", - }, - }, - "summary": "Create a codebase (archive codebases require the archive file)", - "tags": [ - "Codebases", - ], - }, - }, - "/api/v1/codebases/{id}": { - "delete": { - "responses": { - "204": { - "description": "Success", - }, - "404": { - "description": "Codebase not found", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterAgentVersionInput", + }, + }, }, }, - "summary": "Delete a codebase", - "tags": [ - "Codebases", - ], - }, - "get": { "responses": { - "200": { + "201": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CodebaseResponse", + "$ref": "#/components/schemas/AgentVersion", }, }, }, "description": "Success", }, + "400": { + "description": "Validation error", + }, "404": { - "description": "Codebase not found", + "description": "Agent not found", + }, + "409": { + "description": "Queue is assigned to another agent", + }, + "503": { + "description": "Concurrent registration did not stabilize", }, }, - "summary": "Get a codebase", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Register agent version (upsert)", "tags": [ - "Codebases", + "Agents", ], }, + }, + "/api/v1/agents/{id}/versions/{agentVersion}": { "patch": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", + "name": "agentVersion", + "required": true, + "schema": { + "type": "string", + }, + }, + ], "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateCodebaseInput", + "$ref": "#/components/schemas/PatchAgentVersionInput", }, }, }, @@ -4783,69 +5035,131 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CodebaseResponse", + "$ref": "#/components/schemas/AgentVersion", }, }, }, "description": "Success", }, + "400": { + "description": "Invalid status value", + }, "404": { - "description": "Codebase not found", + "description": "Agent or version not found", + }, + "409": { + "description": "Queue is assigned to another agent", + }, + "503": { + "description": "Concurrent activation did not stabilize", }, }, - "summary": "Update a codebase", - "tags": [ - "Codebases", + "security": [ + { + "bearerAuth": [], + }, ], - }, + "summary": "Patch agent version", + "tags": [ + "Agents", + ], + }, }, - "/api/v1/codebases/{id}/revisions": { + "/api/v1/analysis": { "get": { + "description": "Aggregates pass@k / success@T metrics over a single project's done runs. Requires ?projectId=.", "parameters": [ { "in": "query", - "name": "limit", + "name": "worker", + "required": false, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "taskPromptId", + "required": false, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "criteria", + "required": false, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "features", + "required": false, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "submissionId", + "required": false, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "k", "required": false, "schema": { "type": "string", }, }, + { + "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.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "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", + "minLength": 1, + "type": "string", + }, + }, ], "responses": { "200": { "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/CodebaseRevisionResponse", - }, - "type": "array", + "additionalProperties": {}, + "description": "Analysis metrics", + "properties": {}, + "type": "object", }, }, }, "description": "Success", }, - "404": { - "description": "Codebase not found", - }, }, - "summary": "List codebase revisions", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Compute pass@k / success@T metrics", "tags": [ - "Codebases", + "Requests", ], }, - "post": { - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ResolveCodebaseRevisionInput", - }, - }, - }, - }, + }, + "/api/v1/codebase-revisions/{id}": { + "get": { "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -4855,76 +5169,48 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "description": "Success", }, - "400": { - "description": "Not a git codebase", - }, "404": { - "description": "Codebase not found", - }, - "502": { - "description": "GitHub resolution failed", + "description": "Revision not found", }, }, - "summary": "Resolve a new git codebase revision", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Get codebase revision by id", "tags": [ - "Codebases", + "Codebase Revisions", ], }, }, - "/api/v1/codebases/{id}/upload": { - "post": { + "/api/v1/codebase-revisions/{id}/archive": { + "get": { "responses": { - "201": { - "description": "Success", - }, - "400": { - "description": "Missing archive or not an archive codebase", + "200": { + "description": "Binary tar.gz archive", }, "404": { - "description": "Codebase not found", + "description": "Revision or archive not found", + }, + "500": { + "description": "Blob storage error", }, }, - "summary": "Upload a codebase archive as a new revision", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Download codebase revision archive", "tags": [ - "Codebases", + "Codebase Revisions", ], }, }, - "/api/v1/criteria": { + "/api/v1/codebases": { "get": { "parameters": [ - { - "in": "query", - "name": "q", - "required": false, - "schema": { - "type": "string", - }, - }, - { - "description": "Comma-separated criterion IDs to include", - "in": "query", - "name": "ids", - "required": false, - "schema": { - "description": "Comma-separated criterion IDs to include", - "type": "string", - }, - }, - { - "description": "When true and ids is set, also include dependency ancestors", - "in": "query", - "name": "ancestors", - "required": false, - "schema": { - "description": "When true and ids is set, also include dependency ancestors", - "enum": [ - "true", - "false", - ], - "type": "string", - }, - }, { "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.", "in": "query", @@ -4944,7 +5230,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/CriteriaResponse", + "$ref": "#/components/schemas/CodebaseResponse", }, "type": "array", }, @@ -4953,9 +5239,14 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, - "summary": "List criteria", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List all codebases", "tags": [ - "Criteria", + "Codebases", ], }, "post": { @@ -4973,21 +5264,316 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, ], + "responses": { + "201": { + "description": "Success", + }, + "400": { + "description": "Invalid input or missing archive", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Create a codebase (archive codebases require the archive file)", + "tags": [ + "Codebases", + ], + }, + }, + "/api/v1/codebases/{id}": { + "delete": { + "responses": { + "204": { + "description": "Success", + }, + "404": { + "description": "Codebase not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Delete a codebase", + "tags": [ + "Codebases", + ], + }, + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodebaseResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Codebase not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Get a codebase", + "tags": [ + "Codebases", + ], + }, + "patch": { "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateCriteriaInput", + "$ref": "#/components/schemas/UpdateCodebaseInput", }, }, }, }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CriteriaResponse", + "$ref": "#/components/schemas/CodebaseResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Codebase not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Update a codebase", + "tags": [ + "Codebases", + ], + }, + }, + "/api/v1/codebases/{id}/revisions": { + "get": { + "parameters": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CodebaseRevisionResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Codebase not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List codebase revisions", + "tags": [ + "Codebases", + ], + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResolveCodebaseRevisionInput", + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CodebaseRevisionResponse", + }, + }, + }, + "description": "Success", + }, + "400": { + "description": "Not a git codebase", + }, + "404": { + "description": "Codebase not found", + }, + "502": { + "description": "GitHub resolution failed", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Resolve a new git codebase revision", + "tags": [ + "Codebases", + ], + }, + }, + "/api/v1/codebases/{id}/upload": { + "post": { + "responses": { + "201": { + "description": "Success", + }, + "400": { + "description": "Missing archive or not an archive codebase", + }, + "404": { + "description": "Codebase not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Upload a codebase archive as a new revision", + "tags": [ + "Codebases", + ], + }, + }, + "/api/v1/criteria": { + "get": { + "parameters": [ + { + "in": "query", + "name": "q", + "required": false, + "schema": { + "type": "string", + }, + }, + { + "description": "Comma-separated criterion IDs to include", + "in": "query", + "name": "ids", + "required": false, + "schema": { + "description": "Comma-separated criterion IDs to include", + "type": "string", + }, + }, + { + "description": "When true and ids is set, also include dependency ancestors", + "in": "query", + "name": "ancestors", + "required": false, + "schema": { + "description": "When true and ids is set, also include dependency ancestors", + "enum": [ + "true", + "false", + ], + "type": "string", + }, + }, + { + "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.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "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", + "minLength": 1, + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/CriteriaResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List criteria", + "tags": [ + "Criteria", + ], + }, + "post": { + "parameters": [ + { + "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.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "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", + "minLength": 1, + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCriteriaInput", + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CriteriaResponse", }, }, }, @@ -4997,6 +5583,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Criterion already exists", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create criterion", "tags": [ "Criteria", @@ -5007,12 +5598,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "post": { "parameters": [ { - "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.", + "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.", "in": "query", "name": "projectId", - "required": false, + "required": true, "schema": { - "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.", + "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", "minLength": 1, "type": "string", @@ -5089,6 +5680,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "LLM not configured", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Generate criterion prompt from behavior", "tags": [ "Criteria", @@ -5123,6 +5719,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get criteria DAG", "tags": [ "Criteria", @@ -5199,6 +5800,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Compute MDP transitions", "tags": [ "Criteria", @@ -5271,9 +5877,14 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Seed batch would introduce a dependency cycle", }, }, - "summary": "Seed criteria in bulk", - "tags": [ - "Criteria", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Seed criteria in bulk", + "tags": [ + "Criteria", ], }, }, @@ -5331,6 +5942,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Criterion has dependents", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Soft-delete criterion", "tags": [ "Criteria", @@ -5374,6 +5990,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Criterion not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get criterion", "tags": [ "Criteria", @@ -5429,6 +6050,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Criterion not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update criterion", "tags": [ "Criteria", @@ -5466,6 +6092,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List all extensions", "tags": [ "Extensions", @@ -5507,6 +6138,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create or import an extension", "tags": [ "Extensions", @@ -5563,6 +6199,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Missing query parameter", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Search extensions (internal + marketplace)", "tags": [ "Extensions", @@ -5613,6 +6254,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete extension", "tags": [ "Extensions", @@ -5653,6 +6299,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get extension by ID", "tags": [ "Extensions", @@ -5702,6 +6353,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update extension", "tags": [ "Extensions", @@ -5743,6 +6399,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List available versions for an extension", "tags": [ "Extensions", @@ -5766,6 +6427,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List feature flags", "tags": [ "Feature Flags", @@ -5805,6 +6471,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update feature flag", "tags": [ "Feature Flags", @@ -5858,6 +6529,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List insights", "tags": [ "Insights", @@ -5885,6 +6561,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create insight", "tags": [ "Insights", @@ -5938,6 +6619,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Search insights", "tags": [ "Insights", @@ -5979,6 +6665,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete insight", "tags": [ "Insights", @@ -6010,6 +6701,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get insight", "tags": [ "Insights", @@ -6050,6 +6746,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update insight", "tags": [ "Insights", @@ -6083,6 +6784,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Block insight", "tags": [ "Insights", @@ -6116,6 +6822,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Downvote insight", "tags": [ "Insights", @@ -6152,6 +6863,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get reports referencing insight", "tags": [ "Insights", @@ -6185,6 +6901,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Unblock insight", "tags": [ "Insights", @@ -6218,6 +6939,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Insight not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Upvote insight", "tags": [ "Insights", @@ -6241,6 +6967,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List keys", "tags": [ "Keys", @@ -6268,6 +6999,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Key created", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create key", "tags": [ "Keys", @@ -6297,6 +7033,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Key preview", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Preview key", "tags": [ "Keys", @@ -6335,6 +7076,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Key deleted", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete key", "tags": [ "Keys", @@ -6363,6 +7109,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get key", "tags": [ "Keys", @@ -6400,6 +7151,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Key updated", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update key", "tags": [ "Keys", @@ -6442,6 +7198,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Validation result", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Validate key", "tags": [ "Keys", @@ -6479,6 +7240,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List MCP servers", "tags": [ "MCP Servers", @@ -6579,6 +7345,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create MCP server", "tags": [ "MCP Servers", @@ -6629,6 +7400,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete MCP server", "tags": [ "MCP Servers", @@ -6669,6 +7445,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get MCP server", "tags": [ "MCP Servers", @@ -6718,6 +7499,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update MCP server", "tags": [ "MCP Servers", @@ -6771,6 +7557,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List models", "tags": [ "Models", @@ -6892,6 +7683,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Sync models from provider", "tags": [ "Models", @@ -6922,6 +7718,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get model", "tags": [ "Models", @@ -6967,6 +7768,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List profiles", "tags": [ "Profiles", @@ -7008,6 +7814,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create a new profile", "tags": [ "Profiles", @@ -7046,6 +7857,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete profile", "tags": [ "Profiles", @@ -7074,6 +7890,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get profile with latest version", "tags": [ "Profiles", @@ -7153,6 +7974,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create new profile version", "tags": [ "Profiles", @@ -7190,6 +8016,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update profile identity", "tags": [ "Profiles", @@ -7223,6 +8054,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List profile versions", "tags": [ "Profiles", @@ -7261,126 +8097,598 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, }, - "description": "Success", + "description": "Success", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Get profile version", + "tags": [ + "Profiles", + ], + }, + }, + "/api/v1/projects": { + "get": { + "parameters": [ + { + "description": "When true, include soft-deleted projects in the result", + "in": "query", + "name": "includeDeleted", + "required": false, + "schema": { + "description": "When true, include soft-deleted projects in the result", + "enum": [ + "true", + "false", + ], + "type": "string", + }, + }, + { + "in": "query", + "name": "controlPlane", + "required": false, + "schema": { + "enum": [ + "true", + "false", + ], + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ProjectResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List all projects", + "tags": [ + "Projects", + ], + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "creator": { + "type": "string", + }, + "description": { + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse", + }, + }, + }, + "description": "Success", + }, + "400": { + "description": "Invalid input", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Create a project", + "tags": [ + "Projects", + ], + }, + }, + "/api/v1/projects/{id}": { + "delete": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "revision": { + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "revision", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "204": { + "description": "Success", + }, + "404": { + "description": "Project not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Soft-delete a project", + "tags": [ + "Projects", + ], + }, + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Project not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Get a project", + "tags": [ + "Projects", + ], + }, + "patch": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "revision": { + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "revision", + ], + "type": "object", + }, + }, + }, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Project not found", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Update a project", + "tags": [ + "Projects", + ], + }, + }, + "/api/v1/projects/{id}/members": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "_id": { + "type": "string", + }, + "operationId": { + "type": "string", + }, + "pending": { + "type": "boolean", + }, + "projectId": { + "type": "string", + }, + "revision": { + "exclusiveMinimum": 0, + "type": "integer", + }, + "revokedAt": { + "type": "string", + }, + "role": { + "enum": [ + "reader", + "contributor", + "admin", + ], + "type": "string", + }, + "user": { + "properties": { + "alias": { + "type": "string", + }, + "disabled": { + "type": "boolean", + }, + "displayName": { + "type": "string", + }, + "email": { + "type": "string", + }, + "id": { + "type": "string", + }, + "revision": { + "minimum": 0, + "type": "integer", + }, + "role": { + "type": "string", + }, + }, + "required": [ + "id", + "role", + "disabled", + "revision", + ], + "type": "object", + }, + "userId": { + "type": "string", + }, + }, + "required": [ + "_id", + "projectId", + "userId", + "role", + "revision", + "pending", + ], + "type": "object", + }, + "type": "array", + }, + }, + }, + "description": "Success", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "List project memberships (project or platform administration)", + "tags": [ + "Access", + ], + }, + }, + "/api/v1/projects/{id}/members/lookup/{userId}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + }, + }, + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "format": "uuid", + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "alias": { + "type": "string", + }, + "disabled": { + "type": "boolean", + }, + "displayName": { + "type": "string", + }, + "email": { + "type": "string", + }, + "id": { + "type": "string", + }, + "revision": { + "minimum": 0, + "type": "integer", + }, + "role": { + "type": "string", + }, + }, + "required": [ + "id", + "role", + "disabled", + "revision", + ], + "type": "object", + }, + }, + }, + "description": "Success", + }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Look up one enrolled Scope UUID for project membership management", + "tags": [ + "Access", + ], + }, + }, + "/api/v1/projects/{id}/members/search": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + }, + }, + { + "in": "query", + "name": "q", + "required": false, + "schema": { + "default": "", + "maxLength": 100, + "type": "string", + }, }, - }, - "summary": "Get profile version", - "tags": [ - "Profiles", - ], - }, - }, - "/api/v1/projects": { - "get": { - "parameters": [ { - "description": "When true, include soft-deleted projects in the result", "in": "query", - "name": "includeDeleted", + "name": "cursor", "required": false, "schema": { - "description": "When true, include soft-deleted projects in the result", - "enum": [ - "true", - "false", - ], + "maxLength": 200, + "minLength": 1, "type": "string", }, }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 20, + "maximum": 50, + "minimum": 1, + "type": "integer", + }, + }, ], "responses": { "200": { "content": { "application/json": { "schema": { - "items": { - "$ref": "#/components/schemas/ProjectResponse", + "properties": { + "items": { + "items": { + "properties": { + "alias": { + "type": "string", + }, + "disabled": { + "type": "boolean", + }, + "displayName": { + "type": "string", + }, + "email": { + "type": "string", + }, + "id": { + "type": "string", + }, + "revision": { + "minimum": 0, + "type": "integer", + }, + "role": { + "type": "string", + }, + }, + "required": [ + "id", + "role", + "disabled", + "revision", + ], + "type": "object", + }, + "type": "array", + }, + "nextCursor": { + "type": "string", + }, }, - "type": "array", + "required": [ + "items", + ], + "type": "object", }, }, }, "description": "Success", }, }, - "summary": "List all projects", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Search enrolled users to add to this project (project or platform administration)", "tags": [ - "Projects", + "Access", ], }, - "post": { + }, + "/api/v1/projects/{id}/members/{userId}": { + "delete": { "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateProjectInput", - }, - }, - }, - }, - "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectResponse", + "additionalProperties": false, + "properties": { + "revision": { + "minimum": 0, + "type": "integer", + }, }, + "required": [ + "revision", + ], + "type": "object", }, }, - "description": "Success", - }, - "400": { - "description": "Invalid input", - }, - }, - "summary": "Create a project", - "tags": [ - "Projects", - ], - }, - }, - "/api/v1/projects/{id}": { - "delete": { - "responses": { - "204": { - "description": "Success", - }, - "404": { - "description": "Project not found", }, }, - "summary": "Soft-delete a project", - "tags": [ - "Projects", - ], - }, - "get": { "responses": { "200": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectResponse", - }, + "schema": {}, }, }, "description": "Success", }, - "404": { - "description": "Project not found", - }, }, - "summary": "Get a project", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Revoke a project membership", "tags": [ - "Projects", + "Access", ], }, - "patch": { + "put": { "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateProjectInput", + "additionalProperties": false, + "properties": { + "revision": { + "minimum": 0, + "type": "integer", + }, + "role": { + "enum": [ + "reader", + "contributor", + "admin", + ], + "type": "string", + }, + }, + "required": [ + "role", + "revision", + ], + "type": "object", }, }, }, @@ -7389,25 +8697,44 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "200": { "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/ProjectResponse", - }, + "schema": {}, }, }, "description": "Success", }, - "404": { - "description": "Project not found", - }, }, - "summary": "Update a project", + "security": [ + { + "bearerAuth": [], + }, + ], + "summary": "Explicitly grant or change a project membership", "tags": [ - "Projects", + "Access", ], }, }, "/api/v1/projects/{id}/restore": { "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "revision": { + "minimum": 0, + "type": "integer", + }, + }, + "required": [ + "revision", + ], + "type": "object", + }, + }, + }, + }, "responses": { "201": { "content": { @@ -7423,6 +8750,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Project not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Restore a soft-deleted project", "tags": [ "Projects", @@ -7480,6 +8812,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List features", "tags": [ "Prompt Features", @@ -7527,6 +8864,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Feature already exists", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create feature", "tags": [ "Prompt Features", @@ -7537,12 +8879,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "post": { "parameters": [ { - "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.", + "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.", "in": "query", "name": "projectId", - "required": false, + "required": true, "schema": { - "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.", + "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", "minLength": 1, "type": "string", @@ -7615,6 +8957,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "LLM not configured", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Extract features from text", "tags": [ "Prompt Features", @@ -7625,12 +8972,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "post": { "parameters": [ { - "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.", + "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.", "in": "query", "name": "projectId", - "required": false, + "required": true, "schema": { - "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.", + "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", "minLength": 1, "type": "string", @@ -7683,6 +9030,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "LLM not configured", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Generate prompt feature from behavior", "tags": [ "Prompt Features", @@ -7752,6 +9104,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Seed prompt features in bulk", "tags": [ "Prompt Features", @@ -7809,6 +9166,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Feature not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Soft-delete feature", "tags": [ "Prompt Features", @@ -7852,6 +9214,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Feature not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get feature", "tags": [ "Prompt Features", @@ -7907,6 +9274,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Feature not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update feature", "tags": [ "Prompt Features", @@ -7952,6 +9324,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List report templates", "tags": [ "Report Templates", @@ -7996,6 +9373,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Report template already exists", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create report template", "tags": [ "Report Templates", @@ -8027,6 +9409,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List models available for report generation", "tags": [ "Report Templates", @@ -8055,6 +9442,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get default system prompt", "tags": [ "Report Templates", @@ -8108,6 +9500,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Report template not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete report template", "tags": [ "Report Templates", @@ -8150,7 +9547,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "404": { "description": "Report template not found", }, - }, + }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get report template", "tags": [ "Report Templates", @@ -8203,6 +9605,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Report template not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update report template", "tags": [ "Report Templates", @@ -8244,6 +9651,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List reports", "tags": [ "Reports", @@ -8260,23 +9672,21 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReportResponse", - }, - }, - }, - "description": "Success", - }, "400": { "description": "Invalid input", }, "404": { "description": "Run or template not found", }, + "503": { + "description": "New report generation is disabled (report_generation_disabled); no work is enqueued", + }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create report", "tags": [ "Reports", @@ -8295,23 +9705,18 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/ReportResponse", - }, - "type": "array", - }, - }, - }, - "description": "Success", - }, "400": { "description": "Invalid input", }, + "503": { + "description": "New report generation is disabled (report_generation_disabled); no work is enqueued", + }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Bulk create reports", "tags": [ "Reports", @@ -8347,6 +9752,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Invalid input", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Bulk get report statuses", "tags": [ "Reports", @@ -8380,6 +9790,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Invalid input", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Bulk get report summary per run", "tags": [ "Reports", @@ -8398,25 +9813,18 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "items": { - "additionalProperties": {}, - "properties": {}, - "type": "object", - }, - "type": "array", - }, - }, - }, - "description": "Success", - }, "400": { "description": "Invalid input", }, + "503": { + "description": "New report generation is disabled (report_generation_disabled); no work is enqueued", + }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Bulk trigger reports", "tags": [ "Reports", @@ -8435,38 +9843,21 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "reports": { - "items": { - "$ref": "#/components/schemas/ReportResponse", - }, - "type": "array", - }, - "triggered": { - "type": "number", - }, - }, - "required": [ - "triggered", - "reports", - ], - "type": "object", - }, - }, - }, - "description": "Success", - }, "400": { "description": "Invalid input", }, "404": { "description": "Run not found", }, + "503": { + "description": "New report generation is disabled (report_generation_disabled); no work is enqueued", + }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Trigger reports", "tags": [ "Reports", @@ -8500,6 +9891,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Report not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get report", "tags": [ "Reports", @@ -8556,6 +9952,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Report not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get insights for report", "tags": [ "Reports", @@ -8593,43 +9994,21 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, }, "responses": { - "201": { - "content": { - "application/json": { - "schema": { - "properties": { - "insightId": { - "type": "string", - }, - "isNew": { - "type": "boolean", - }, - "referencedAt": { - "format": "date-time", - "type": [ - "string", - "null", - ], - }, - }, - "required": [ - "insightId", - "referencedAt", - "isNew", - ], - "type": "object", - }, - }, - }, - "description": "Success", - }, "404": { "description": "Report or insight not found", }, "409": { "description": "Insight already referenced by this report", }, + "503": { + "description": "New report generation is disabled (report_generation_disabled); no work is enqueued", + }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Link insight to report", "tags": [ "Reports", @@ -8656,6 +10035,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Report not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Stream report logs (SSE)", "tags": [ "Reports", @@ -9028,6 +10412,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List requests", "tags": [ "Requests", @@ -9107,6 +10496,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Submit request(s)", "tags": [ "Requests", @@ -9148,6 +10542,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "One or more runs not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download batch archive of multiple runs", "tags": [ "Requests", @@ -9196,6 +10595,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Bulk soft-delete requests", "tags": [ "Requests", @@ -9275,6 +10679,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Cancel multiple requests", "tags": [ "Requests", @@ -9329,6 +10738,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Pause multiple requests", "tags": [ "Requests", @@ -9387,6 +10801,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Set priority on multiple requests", "tags": [ "Requests", @@ -9419,6 +10838,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Bulk resubmit requests", "tags": [ "Requests", @@ -9473,6 +10897,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Resume multiple paused requests", "tags": [ "Requests", @@ -9553,6 +10982,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Bulk retry requests (start new attempts)", "tags": [ "Requests", @@ -9588,6 +11022,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List run filter facets", "tags": [ "Requests", @@ -9629,6 +11068,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Soft-delete request", "tags": [ "Requests", @@ -9660,6 +11104,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get request", "tags": [ "Requests", @@ -9686,6 +11135,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download full run archive", "tags": [ "Requests", @@ -9725,6 +11179,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download ATIF trajectory file for a specific iteration", "tags": [ "Requests", @@ -9765,6 +11224,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Cancel a request (marks as done/failed and signals worker to exit)", "tags": [ "Requests", @@ -9791,6 +11255,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download HAR file", "tags": [ "Requests", @@ -9817,6 +11286,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Stream request logs (SSE)", "tags": [ "Requests", @@ -9849,6 +11323,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Pause a single request", "tags": [ "Requests", @@ -9898,6 +11377,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Set priority on a single request", "tags": [ "Requests", @@ -9934,6 +11418,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Run not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get reports for request", "tags": [ "Reports", @@ -9966,6 +11455,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Resume a paused request", "tags": [ "Requests", @@ -10035,6 +11529,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Cannot retry — current run not yet terminal", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Retry a request (start a new attempt)", "tags": [ "Requests", @@ -10071,6 +11570,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Request not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List attempts for a request", "tags": [ "Requests", @@ -10112,6 +11616,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Request or run not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get a single attempt", "tags": [ "Requests", @@ -10146,6 +11655,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download full run archive for a specific attempt", "tags": [ "Requests", @@ -10193,6 +11707,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download ATIF trajectory file for a specific attempt and iteration", "tags": [ "Requests", @@ -10227,6 +11746,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download HAR file for a specific attempt", "tags": [ "Requests", @@ -10261,6 +11785,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Stream logs for a specific attempt (SSE)", "tags": [ "Requests", @@ -10303,6 +11832,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download iteration snapshot for a specific attempt", "tags": [ "Requests", @@ -10337,6 +11871,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download per-iteration tool-calls JSONL for a specific attempt", "tags": [ "Requests", @@ -10371,6 +11910,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download session recording for a specific attempt", "tags": [ "Requests", @@ -10405,6 +11949,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download iteration snapshot", "tags": [ "Requests", @@ -10431,6 +11980,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download per-iteration tool-calls JSONL", "tags": [ "Requests", @@ -10457,6 +12011,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download session recording", "tags": [ "Requests", @@ -10494,6 +12053,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List all resources", "tags": [ "Resources", @@ -10534,6 +12098,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Resource slug or revision ref already exists in this project", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create a resource and its first revision", "tags": [ "Resources", @@ -10579,6 +12148,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Revision not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get resource revision by id", "tags": [ "Resource Revisions", @@ -10617,6 +12191,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Resource not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Delete a resource", "tags": [ "Resources", @@ -10660,6 +12239,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Resource not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get a resource", "tags": [ "Resources", @@ -10712,6 +12296,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Resource not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Update a resource", "tags": [ "Resources", @@ -10768,6 +12357,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Resource not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List resource revisions", "tags": [ "Resource Revisions", @@ -10816,6 +12410,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Resource revision ref already exists in this project", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create a resource revision", "tags": [ "Resource Revisions", @@ -10861,6 +12460,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Revision not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get the latest resource revision", "tags": [ "Resource Revisions", @@ -10914,6 +12518,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Revision not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get a resource revision by resource and revision number", "tags": [ "Resource Revisions", @@ -10941,6 +12550,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Import run archive", "tags": [ "Requests", @@ -10971,6 +12585,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "No archive uploaded, empty archive, or all runs failed", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Import batch run archive", "tags": [ "Requests", @@ -11008,6 +12627,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Skill revision not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get skill revision by ref", "tags": [ "Skill Revisions", @@ -11041,6 +12665,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Blob storage error", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Download skill revision archive", "tags": [ "Skill Revisions", @@ -11064,6 +12693,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Skill revision not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get skill revision by ID", "tags": [ "Skill Revisions", @@ -11101,6 +12735,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List all skills", "tags": [ "Skills", @@ -11142,6 +12781,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create or import a skill", "tags": [ "Skills", @@ -11196,6 +12840,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "GitHub API error", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Discover skills in a GitHub repository", "tags": [ "Skills", @@ -11252,6 +12901,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Missing query parameter", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Search skills (internal + external)", "tags": [ "Skills", @@ -11307,6 +12961,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Missing query parameter", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Search external skills registry", "tags": [ "Skills", @@ -11337,6 +12996,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Skill not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Soft-delete a skill", "tags": [ "Skills", @@ -11372,6 +13036,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Skill not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get skill by slug", "tags": [ "Skills", @@ -11412,6 +13081,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Blob storage error", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Trigger skill resolution", "tags": [ "Skills", @@ -11460,6 +13134,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Skill not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List skill revisions", "tags": [ "Skills", @@ -11555,6 +13234,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "List task prompts", "tags": [ "Task Prompts", @@ -11599,6 +13283,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Empty text string", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Create or find task prompt", "tags": [ "Task Prompts", @@ -11607,6 +13296,20 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "/api/v1/task-prompts/generate": { "post": { + "parameters": [ + { + "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.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "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", + "minLength": 1, + "type": "string", + }, + }, + ], "requestBody": { "content": { "application/json": { @@ -11650,6 +13353,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "LLM not configured", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Generate task prompts", "tags": [ "Task Prompts", @@ -11691,6 +13399,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Task prompt not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Soft-delete task prompt", "tags": [ "Task Prompts", @@ -11722,6 +13435,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Task prompt not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get task prompt", "tags": [ "Task Prompts", @@ -11767,6 +13485,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Task prompt not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Get resolved task prompt content", "tags": [ "Task Prompts", @@ -11857,6 +13580,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "LLM not configured", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Extract features from task prompt", "tags": [ "Task Prompts", @@ -11910,6 +13638,11 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Task prompt or feature not found", }, }, + "security": [ + { + "bearerAuth": [], + }, + ], "summary": "Toggle feature flag on task prompt", "tags": [ "Task Prompts", @@ -11951,7 +13684,18 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, "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.", + "description": "JIT-enroll the authenticated user with the default user role, refresh their profile and lastLoginAt, and warm the access cache. Login never grants administrative privileges. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": {}, + "type": "object", + }, + }, + }, + }, "responses": { "200": { "content": { @@ -11992,6 +13736,9 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "application/json": { "schema": { "properties": { + "authEnabled": { + "type": "boolean", + }, "buildTime": { "type": "string", }, @@ -12010,6 +13757,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "buildTime", "environment", "strictAgentCapabilities", + "authEnabled", ], "type": "object", }, @@ -12018,6 +13766,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [], "summary": "Version info", "tags": [ "System", @@ -12050,6 +13799,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Success", }, }, + "security": [], "summary": "Liveness probe", "tags": [ "Health", @@ -12082,6 +13832,7 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "description": "Service is not ready", }, }, + "security": [], "summary": "Readiness probe", "tags": [ "Health", diff --git a/apps/api/src/auth/admin-operations.integration.test.ts b/apps/api/src/auth/admin-operations.integration.test.ts new file mode 100644 index 000000000..0d756cf2a --- /dev/null +++ b/apps/api/src/auth/admin-operations.integration.test.ts @@ -0,0 +1,447 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { MongoClient, type Db, type Document } from "mongodb"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { AdminOperations, type AdminIntent } from "./admin-operations.js"; +import { Authorization } from "./authorization.js"; +import { operatorAccess, operatorOperation, OperatorAccessInput } from "./operator-access.js"; +import { mapLegacyMemberships, LegacyMembershipManifest } from "./legacy-memberships.js"; +import { UserStore } from "./user-store.js"; +import type { UserDocument } from "shared"; + +const uri = process.env.AUTH_TEST_MONGO_URI; +describe.runIf(Boolean(uri))("Durable administrative operation failure recovery", () => { + const database = `rbac-operations-test-${randomUUID()}`; + let client: MongoClient; + let db: Db; + let service: AdminOperations; + const intent = (overrides: Partial = {}): AdminIntent => ({ + _id: randomUUID(), actor: "verified-platform-admin", reason: "Acceptance fixture", + collection: "project_memberships", targetId: randomUUID(), expectedRevision: 0, + change: { role: "reader" }, unset: [], + initial: { userId: randomUUID(), projectId: randomUUID(), createdByUserId: "verified-platform-admin" }, + ...overrides, + }); + const read = (collection: string, id: string) => + db.collection(collection).findOne({ _id: id }); + + beforeAll(async () => { + if (!uri) throw new Error("AUTH_TEST_MONGO_URI required"); + client = new MongoClient(uri); + await client.connect(); + db = client.db(database); + service = new AdminOperations(db); + await db.collection("project_memberships").createIndex({ projectId: 1, userId: 1 }, { unique: true }); + }); + afterEach(() => vi.restoreAllMocks()); + afterAll(async () => { if (db) await db.dropDatabase(); await client?.close(); }); + + function intercept() { + const original = db.collection.bind(db); + const operations = original("admin_operations"); + const target = original("project_memberships"); + vi.spyOn(db, "collection").mockImplementation(name => + name === "admin_operations" ? operations : name === "project_memberships" ? target : original(name)); + return { operations, target }; + } + + function projectCreation(): AdminIntent { + return intent({ + collection: "projects", targetId: randomUUID(), initial: {}, + change: { name: "Creator membership fixture" }, + creatorMembershipOperationId: randomUUID(), + }); + } + + it("audits project creation and grants only its creator one project-admin membership", async () => { + const creation = projectCreation(); + expect(await service.execute(creation)).toMatchObject({ state: "completed", revision: 1 }); + expect(await service.execute(creation)).toMatchObject({ state: "completed", revision: 1 }); + expect(await read("project_memberships", `${creation.targetId}:${creation.actor}`)).toMatchObject({ + projectId: creation.targetId, userId: creation.actor, role: "admin", revision: 1, + lastOperationId: creation.creatorMembershipOperationId, + }); + expect(await read("admin_operations", creation.creatorMembershipOperationId!)).toMatchObject({ + actor: creation.actor, state: "completed", reason: "Project creator project-admin membership", + }); + expect(await db.collection("project_memberships").countDocuments({ projectId: creation.targetId })).toBe(1); + expect(await new Authorization(db).memberships(creation.actor)).toEqual(expect.arrayContaining([ + expect.objectContaining({ projectId: creation.targetId, role: "admin" }), + ])); + }); + + it("writes neither project nor creator membership when the parent intent cannot be persisted", async () => { + const creation = projectCreation(); + vi.spyOn(intercept().operations, "insertOne").mockRejectedValueOnce(new Error("Intent store unavailable")); + await expect(service.execute(creation)).rejects.toMatchObject({ status: 503 }); + expect(await read("projects", creation.targetId)).toBeNull(); + expect(await read("project_memberships", `${creation.targetId}:${creation.actor}`)).toBeNull(); + }); + + it("keeps the parent pending and resumes both steps if the creator intent cannot be persisted", async () => { + const creation = projectCreation(); + const { operations } = intercept(); + const insert = operations.insertOne.bind(operations); + const failure = vi.spyOn(operations, "insertOne").mockImplementation(async (...args) => { + if (args[0]._id === creation.creatorMembershipOperationId) throw new Error("Creator intent unavailable"); + return insert(...args); + }); + expect(await service.execute(creation)).toMatchObject({ operationId: creation._id, state: "pending" }); + expect(await service.isComplete(creation._id)).toBe(false); + expect(await read("project_memberships", `${creation.targetId}:${creation.actor}`)).toBeNull(); + failure.mockRestore(); + expect(await service.reconcile(creation._id)).toMatchObject({ state: "completed" }); + expect(await read("project_memberships", `${creation.targetId}:${creation.actor}`)).toMatchObject({ role: "admin", revision: 1 }); + }); + + it("recovers a creator grant with lost acknowledgement without granting early access", async () => { + const creation = projectCreation(); + const { target } = intercept(); + const insert = target.insertOne.bind(target); + vi.spyOn(target, "insertOne").mockImplementationOnce(async (...args) => { + await insert(...args); + throw new Error("Creator grant acknowledgement lost"); + }); + expect(await service.execute(creation)).toMatchObject({ operationId: creation._id, state: "pending" }); + expect((await new Authorization(db).memberships(creation.actor)).some(m => m.projectId === creation.targetId)).toBe(false); + expect(await service.reconcile(creation._id)).toMatchObject({ state: "completed" }); + expect(await read("project_memberships", `${creation.targetId}:${creation.actor}`)).toMatchObject({ + role: "admin", revision: 1, appliedOperationIds: [creation.creatorMembershipOperationId], + }); + }); + + it("withholds creator access until parent completion and preserves a later revocation during recovery", async () => { + const creation = projectCreation(); + const { operations } = intercept(); + const update = operations.updateOne.bind(operations); + const failure = vi.spyOn(operations, "updateOne").mockImplementation(async (...args) => { + if (args[0]._id === creation._id) throw new Error("Project completion unavailable"); + return update(...args); + }); + expect(await service.execute(creation)).toMatchObject({ operationId: creation._id, state: "pending" }); + expect(await service.isComplete(creation.creatorMembershipOperationId)).toBe(true); + expect((await new Authorization(db).memberships(creation.actor)).some(m => m.projectId === creation.targetId)).toBe(false); + await service.execute(intent({ + targetId: `${creation.targetId}:${creation.actor}`, initial: undefined, expectedRevision: 1, + change: { revokedAt: "$operationTime" }, + })); + failure.mockRestore(); + expect(await service.reconcile(creation._id)).toMatchObject({ state: "completed" }); + expect(await service.execute(creation)).toMatchObject({ state: "completed" }); + expect(await read("project_memberships", `${creation.targetId}:${creation.actor}`)).toMatchObject({ + revokedAt: expect.any(Date), revision: 2, + }); + expect((await new Authorization(db).memberships(creation.actor)).some(m => m.projectId === creation.targetId)).toBe(false); + }); + + it("coalesces concurrent identical project creation and creator membership into one grant", async () => { + const creation = projectCreation(); + const results = await Promise.all([service.execute(creation), service.execute(creation)]); + expect(results.every(result => result.state === "completed")).toBe(true); + expect(await db.collection("project_memberships").countDocuments({ projectId: creation.targetId })).toBe(1); + expect(await read("project_memberships", `${creation.targetId}:${creation.actor}`)).toMatchObject({ + role: "admin", revision: 1, appliedOperationIds: [creation.creatorMembershipOperationId], + }); + }); + + it("persists nothing on the target when intent storage fails", async () => { + const change = intent(); + vi.spyOn(intercept().operations, "insertOne").mockRejectedValueOnce(new Error("Intent store unavailable")); + await expect(service.execute(change)).rejects.toMatchObject({ status: 503, code: "intent_unavailable" }); + expect(await read(change.collection, change.targetId)).toBeNull(); + }); + + it("reuses identical input and rejects a conflicting idempotency payload", async () => { + const change = intent(); + expect(await service.execute(change)).toMatchObject({ state: "completed", revision: 1 }); + expect(await service.execute(change)).toMatchObject({ state: "completed", revision: 1 }); + await expect(service.execute({ ...change, change: { role: "admin" } })).rejects.toMatchObject({ status: 409, code: "idempotency_conflict" }); + expect(await read(change.collection, change.targetId)).toMatchObject({ role: "reader", revision: 1 }); + }); + + it("records a competing revision conflict without applying its role", async () => { + const first = intent(); + await service.execute(first); + const second = intent({ targetId: first.targetId, change: { role: "admin" } }); + await expect(service.execute(second)).rejects.toMatchObject({ status: 409, code: "revision_conflict" }); + expect(await read("admin_operations", second._id)).toMatchObject({ state: "conflict" }); + expect(await read(first.collection, first.targetId)).toMatchObject({ role: "reader", revision: 1 }); + }); + + it("does not create an absent target using a nonzero revision", async () => { + const change = intent({ expectedRevision: 4 }); + await expect(service.execute(change)).rejects.toMatchObject({ status: 409 }); + expect(await read(change.collection, change.targetId)).toBeNull(); + }); + + it("recovers an interruption after intent but before target mutation", async () => { + const change = intent(); + vi.spyOn(intercept().target, "insertOne").mockRejectedValueOnce(new Error("Crash before insert")); + expect(await service.execute(change)).toMatchObject({ state: "pending" }); + expect(await read(change.collection, change.targetId)).toBeNull(); + expect(await service.reconcile(change._id)).toMatchObject({ state: "completed", revision: 1 }); + expect(await read(change.collection, change.targetId)).toMatchObject({ role: "reader", revision: 1 }); + }); + + it("recovers an applied write with a lost acknowledgement exactly once", async () => { + const change = intent(); + const { target } = intercept(); + const insert = target.insertOne.bind(target); + vi.spyOn(target, "insertOne").mockImplementationOnce(async (...args) => { + await insert(...args); + throw new Error("Connection lost after server applied insert"); + }); + expect(await service.execute(change)).toMatchObject({ state: "pending" }); + expect(await service.isComplete(change._id)).toBe(false); + expect(await service.reconcile(change._id)).toMatchObject({ state: "completed", revision: 1 }); + expect(await read(change.collection, change.targetId)).toMatchObject({ revision: 1, appliedOperationIds: [change._id] }); + }); + + it("withholds completion on failed audit completion and reconciles after a newer operation", async () => { + const first = intent(); + vi.spyOn(intercept().operations, "updateOne").mockRejectedValueOnce(new Error("Completion store unavailable")); + expect(await service.execute(first)).toMatchObject({ state: "pending" }); + expect(await service.isComplete(first._id)).toBe(false); + const second = intent({ targetId: first.targetId, expectedRevision: 1, change: { role: "contributor" } }); + expect(await service.execute(second)).toMatchObject({ state: "completed", revision: 2 }); + expect(await service.reconcile(first._id)).toMatchObject({ state: "completed", revision: 1 }); + expect(await read(first.collection, first.targetId)).toMatchObject({ + role: "contributor", revision: 2, lastOperationId: second._id, + appliedOperationIds: [first._id, second._id], + }); + }); + + it("reports pending when completion matches no durable intent", async () => { + const change = intent(); + vi.spyOn(intercept().operations, "updateOne").mockResolvedValueOnce({ + acknowledged: true, matchedCount: 0, modifiedCount: 0, upsertedCount: 0, upsertedId: null, + }); + expect(await service.execute(change)).toMatchObject({ state: "pending" }); + expect(await service.isComplete(change._id)).toBe(false); + expect(await read(change.collection, change.targetId)).toMatchObject({ revision: 1 }); + expect(await service.reconcile(change._id)).toMatchObject({ state: "completed", revision: 1 }); + }); + + it("requires an acknowledged completion write before reporting success", async () => { + const change = intent(); + vi.spyOn(intercept().operations, "updateOne").mockResolvedValueOnce({ + acknowledged: false, matchedCount: 0, modifiedCount: 0, upsertedCount: 0, upsertedId: null, + }); + expect(await service.execute(change)).toMatchObject({ state: "pending" }); + expect(await service.isComplete(change._id)).toBe(false); + expect(await service.reconcile(change._id)).toMatchObject({ state: "completed", revision: 1 }); + }); + + it("coalesces competing identical operations without confusing a completed marker for a missing write", async () => { + const change = intent(); + const results = await Promise.all([service.execute(change), service.execute(change)]); + expect(results).toEqual([ + { operationId: change._id, state: "completed", revision: 1 }, + { operationId: change._id, state: "completed", revision: 1 }, + ]); + expect(await read(change.collection, change.targetId)).toMatchObject({ + revision: 1, appliedOperationIds: [change._id], + }); + }); + + it("revokes/regrants a single membership using retained revisions and no duplicate row", async () => { + const grant = intent(); + await service.execute(grant); + const revoke = intent({ targetId: grant.targetId, expectedRevision: 1, initial: undefined, change: { revokedAt: "$operationTime" } }); + await service.execute(revoke); + expect(await read(grant.collection, grant.targetId)).toMatchObject({ revokedAt: expect.any(Date), revision: 2 }); + await service.execute(intent({ targetId: grant.targetId, expectedRevision: 2, change: { role: "admin" }, unset: ["revokedAt"] })); + const membership = await read(grant.collection, grant.targetId); + expect(membership).toMatchObject({ role: "admin", revision: 3 }); + expect(membership).not.toHaveProperty("revokedAt"); + expect(await db.collection(grant.collection).countDocuments({ userId: grant.initial?.userId })).toBe(1); + }); + + it("rechecks role in the final ordinary-user disable CAS after operator promotion", async () => { + const id = randomUUID(); + await db.collection("users").insertOne({ _id: id, role: "admin", revision: 1 }); + const disable = intent({ collection: "users", targetId: id, initial: undefined, expectedRevision: 0, + change: { disabledAt: "$operationTime" }, condition: { role: "user" } }); + await expect(service.execute(disable)).rejects.toMatchObject({ status: 409 }); + expect(await read("users", id)).not.toHaveProperty("disabledAt"); + }); + + it("coalesces concurrent exact-identity bootstrap into one durable promotion", async () => { + const bootstrapDb = client.db(`rbac_bootstrap_${randomUUID().replaceAll("-", "")}`); + try { + const users = bootstrapDb.collection("users"); + await users.createIndex({ idp: 1, idpTenant: 1, idpSubject: 1 }, { unique: true, name: "uniq_identity" }); + const identity = { idp: "entra", idpTenant: "fixture-tenant", idpSubject: randomUUID() }; + const store = new UserStore(users); + const [first, second] = await Promise.all([ + store.upsertOnLogin(identity, {}), store.upsertOnLogin(identity, {}), + ]); + expect(first._id).toBe(second._id); + expect(first.role).toBe("user"); + expect(second.role).toBe("user"); + const input = { + database: bootstrapDb.databaseName, confirmDatabase: bootstrapDb.databaseName, + idp: "entra", tenant: identity.idpTenant, subject: identity.idpSubject, userId: first._id, + revision: 0, role: "admin", disabled: "false", operator: "bootstrap-test", reason: "Explicit bootstrap", + operationId: randomUUID(), bootstrap: true, apply: true, + }; + await Promise.all([operatorAccess(bootstrapDb, input), operatorAccess(bootstrapDb, input)]); + expect(await users.findOne({ _id: first._id })).toMatchObject({ role: "admin", revision: 1 }); + expect(await users.countDocuments()).toBe(1); + expect(await bootstrapDb.collection("admin_operations").countDocuments({ state: "completed" })).toBe(1); + expect(await bootstrapDb.collection("project_memberships").countDocuments()).toBe(0); + } finally { await bootstrapDb.dropDatabase(); } + }); + + it("does not let bootstrap overwrite a concurrent targeted operator disable", async () => { + const users = db.collection("users"); + const identity = { idp: "entra", idpTenant: "fixture-tenant", idpSubject: randomUUID() }; + const store = new UserStore(users); + const user = await store.upsertOnLogin(identity, {}); + const execute = AdminOperations.prototype.execute; + vi.spyOn(AdminOperations.prototype, "execute").mockImplementationOnce(async function(this: AdminOperations, change) { + await operatorAccess(db, { + database, confirmDatabase: database, ...{ idp: "entra", tenant: identity.idpTenant, subject: identity.idpSubject }, + userId: change.targetId, revision: 0, role: "user", disabled: "true", + operator: "fixture-operator", reason: "Concurrent exact-identity disable", operationId: randomUUID(), apply: true, + }); + return execute.call(this, change); + }); + await expect(operatorAccess(db, { + database, confirmDatabase: database, idp: "entra", tenant: identity.idpTenant, subject: identity.idpSubject, + userId: user._id, revision: 0, role: "admin", disabled: "false", bootstrap: true, apply: true, + operator: "fixture-operator", reason: "Bootstrap race", operationId: randomUUID(), + })).rejects.toMatchObject({ code: "revision_conflict" }); + const disabled = await users.findOne(identity); + expect(disabled).toMatchObject({ role: "user", disabledAt: expect.any(Date), revision: 1 }); + expect(await store.upsertOnLogin(identity, {})).toMatchObject({ role: "user", disabledAt: expect.any(Date) }); + }); + + it("requires the exact operator identity/UUID/database and preserves membership on recovery", async () => { + const userId = randomUUID(), projectId = randomUUID(); + const subject = randomUUID(); + await db.collection("users").insertOne({ + _id: userId, role: "user", idp: "entra", idpTenant: "fixture-tenant", idpSubject: subject, + }); + const input = OperatorAccessInput.parse({ + database, confirmDatabase: database, idp: "entra", tenant: "fixture-tenant", subject, + userId, revision: 0, role: "admin", disabled: "false", + operator: "fixture-operator", reason: "Targeted fixture recovery", operationId: randomUUID(), apply: true, + }); + await expect(operatorAccess(db, { ...input, subject: "different" })).rejects.toMatchObject({ code: "identity_mismatch" }); + await expect(operatorAccess(db, { ...input, confirmDatabase: "wrong" })).rejects.toMatchObject({ code: "database_mismatch" }); + const membership = intent({ targetId: `${projectId}:${userId}`, initial: { userId, projectId, createdByUserId: "fixture" } }); + await service.execute(membership); + expect(await operatorAccess(db, input)).toMatchObject({ state: "completed" }); + expect(await operatorAccess(db, input)).toMatchObject({ state: "completed" }); + expect(await read("users", userId)).toMatchObject({ _id: userId, role: "admin", revision: 1, idpSubject: subject }); + expect(await db.collection("admin_operations").findOne({ _id: input.operationId })).toMatchObject({ backup: { role: "user" }, state: "completed" }); + expect(await read("project_memberships", membership.targetId)).toMatchObject({ role: "reader", revision: 1 }); + }); + + async function enrolledOperatorFixture() { + const identity = { idp: "entra", idpTenant: "operator-fixture", idpSubject: randomUUID() }; + const store = new UserStore(db.collection("users")); + const user = await store.upsertOnLogin(identity, {}); + return { + database, confirmDatabase: database, idp: "entra", tenant: identity.idpTenant, subject: identity.idpSubject, + userId: user._id, revision: 0, role: "admin", disabled: "false", + operator: "bootstrap-acceptance", reason: "Reviewed bootstrap fixture", operationId: randomUUID(), bootstrap: true, + }; + } + + it("defaults bootstrap to read-only preview, applies once, and does not re-promote after later demotion", async () => { + const input = await enrolledOperatorFixture(); + const before = await read("users", input.userId); + const count = await db.collection("admin_operations").countDocuments(); + expect(await operatorAccess(db, input)).toMatchObject({ state: "dry-run", currentRole: "user", currentRevision: 0 }); + expect(await read("users", input.userId)).toEqual(before); + expect(await db.collection("admin_operations").countDocuments()).toBe(count); + expect(await operatorAccess(db, { ...input, apply: true })).toMatchObject({ state: "completed", revision: 1 }); + expect(await operatorAccess(db, { ...input, apply: true })).toMatchObject({ state: "completed", revision: 1 }); + await operatorAccess(db, { ...input, bootstrap: false, role: "user", revision: 1, operationId: randomUUID(), apply: true }); + expect(await operatorAccess(db, { ...input, apply: true })).toMatchObject({ state: "completed", revision: 1 }); + expect(await read("users", input.userId)).toMatchObject({ role: "user", revision: 2 }); + }); + + it("rejects absent identities, mismatched UUIDs, stale revisions and different-input operation IDs", async () => { + const input = await enrolledOperatorFixture(); + await expect(operatorAccess(db, { ...input, userId: randomUUID(), apply: true })).rejects.toMatchObject({ code: "identity_mismatch" }); + await expect(operatorAccess(db, { ...input, revision: 8, apply: true })).rejects.toMatchObject({ code: "revision_conflict" }); + await operatorAccess(db, { ...input, apply: true }); + await expect(operatorAccess(db, { ...input, reason: "Changed payload", apply: true })).rejects.toMatchObject({ code: "idempotency_conflict" }); + await expect(operatorAccess(db, { ...input, revision: undefined, apply: true })).rejects.toThrow(/requires --revision/); + await expect(operatorAccess(db, { ...input, operationId: undefined, apply: true })).rejects.toThrow(/requires --revision/); + }); + + it("makes already-active admin bootstrap a no-op but requires explicit account re-enablement", async () => { + const input = await enrolledOperatorFixture(); + await operatorAccess(db, { ...input, apply: true }); + const before = await read("users", input.userId); + expect(await operatorAccess(db, { ...input, revision: 1, operationId: randomUUID(), apply: true })).toMatchObject({ state: "unchanged" }); + expect(await read("users", input.userId)).toEqual(before); + await operatorAccess(db, { ...input, bootstrap: false, disabled: "true", revision: 1, operationId: randomUUID(), apply: true }); + await expect(operatorAccess(db, { ...input, revision: 2, operationId: randomUUID(), apply: true })).rejects.toMatchObject({ code: "user_disabled" }); + await operatorAccess(db, { ...input, bootstrap: false, revision: 2, operationId: randomUUID(), apply: true }); + expect(await read("users", input.userId)).toMatchObject({ role: "admin", revision: 3 }); + expect(await read("users", input.userId)).not.toHaveProperty("disabledAt"); + }); + + it("allows only operator repair to inspect and finish the original incomplete intent, without exposing backups", async () => { + const input = await enrolledOperatorFixture(); + const original = db.collection.bind(db); + const operations = original("admin_operations"); + vi.spyOn(db, "collection").mockImplementation(name => name === "admin_operations" ? operations : original(name)); + const update = operations.updateOne.bind(operations); + vi.spyOn(operations, "updateOne").mockImplementationOnce(async () => { throw new Error("Injected lost completion"); }); + expect(await operatorAccess(db, { ...input, apply: true })).toMatchObject({ state: "pending" }); + const pending = await read("admin_operations", input.operationId); + const command = { database, confirmDatabase: database, operator: "repair-operator", reason: "Repair fixture", operationId: input.operationId }; + expect(await operatorOperation(db, command)).toEqual({ + state: "inspection", operationId: input.operationId, operationState: "prepared", + collection: "users", targetId: input.userId, expectedRevision: 0, + }); + expect(await read("admin_operations", input.operationId)).toEqual(pending); + await expect(operatorAccess(db, { ...input, operationId: randomUUID(), revision: 1, apply: true })).rejects.toMatchObject({ code: "operation_pending" }); + vi.mocked(operations.updateOne).mockImplementation(update); + expect(await operatorOperation(db, { ...command, apply: true })).toMatchObject({ state: "completed", revision: 1 }); + expect(await read("admin_operations", input.operationId)).toMatchObject({ actor: "operator:bootstrap-acceptance", fingerprint: pending?.fingerprint, state: "completed" }); + expect(await read("users", input.userId)).toMatchObject({ role: "admin", revision: 1 }); + await expect(operatorOperation(db, { ...command, confirmDatabase: "wrong", apply: true })).rejects.toMatchObject({ code: "database_mismatch" }); + await expect(operatorOperation(db, { ...command, operationId: "missing" })).rejects.toMatchObject({ code: "not_found" }); + }); + + it("dry-runs and reruns only explicitly reviewed legacy mappings without implicit defaults", async () => { + const projectId = randomUUID(), unmappedProject = randomUUID(), userId = randomUUID(); + await db.collection("projects").insertMany([{ _id: projectId }, { _id: unmappedProject }]); + await db.collection("users").insertOne({ _id: userId, role: "user" }); + const manifest = LegacyMembershipManifest.parse({ + database, reviewedBy: "fixture-reviewer", reviewedAt: new Date().toISOString(), reason: "Synthetic explicit mapping", + entries: [{ projectId, userId, role: "reader", revision: 0, operationId: randomUUID() }], + }); + expect(await mapLegacyMemberships(db, manifest, false)).toEqual({ dryRun: true, entries: 1 }); + expect(await read("project_memberships", `${projectId}:${userId}`)).toBeNull(); + await mapLegacyMemberships(db, manifest, true); + await mapLegacyMemberships(db, manifest, true); + expect(await db.collection("project_memberships").countDocuments({ projectId, userId })).toBe(1); + expect(await db.collection("project_memberships").countDocuments({ projectId: unmappedProject })).toBe(0); + expect(LegacyMembershipManifest.safeParse({ ...manifest, entries: [...manifest.entries, ...manifest.entries] }).success).toBe(false); + }); + + it("validates all legacy mapping parents before granting the first entry", async () => { + const projectId = randomUUID(), userId = randomUUID(); + await db.collection("projects").insertOne({ _id: projectId }); + await db.collection("users").insertOne({ _id: userId, role: "user" }); + const manifest = LegacyMembershipManifest.parse({ + database, reviewedBy: "fixture-reviewer", reviewedAt: new Date().toISOString(), reason: "Synthetic rejected mapping", + entries: [ + { projectId, userId, role: "reader", revision: 0, operationId: randomUUID() }, + { projectId: randomUUID(), userId, role: "admin", revision: 0, operationId: randomUUID() }, + ], + }); + await expect(mapLegacyMemberships(db, manifest, true)).rejects.toMatchObject({ status: 404 }); + expect(await read("project_memberships", `${projectId}:${userId}`)).toBeNull(); + }); +}); diff --git a/apps/api/src/auth/admin-operations.ts b/apps/api/src/auth/admin-operations.ts new file mode 100644 index 000000000..db93adbc1 --- /dev/null +++ b/apps/api/src/auth/admin-operations.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { createHash } from "node:crypto"; +import { MongoServerError, type Db, type Document, type Filter } from "mongodb"; + +export class AccessError extends Error { + constructor(public readonly status: number, public readonly code: string, message: string) { + super(message); + } +} + +export type SecurityTarget = "users" | "projects" | "project_memberships"; +export interface AdminIntent { + _id: string; + actor: string; + reason: string; + collection: SecurityTarget; + targetId: string; + expectedRevision: number; + change: Record; + unset: string[]; + initial?: Record; + condition?: Record; + backup?: Record; + creatorMembershipOperationId?: string; +} +export interface AdminOperation extends AdminIntent { + fingerprint: string; + state: "prepared" | "completed" | "conflict"; + createdAt: Date; + completedAt?: Date; +} +export interface OperationResult { + operationId: string; + state: "completed" | "pending"; + revision: number; +} + +function stable(value: unknown): string { + if (value instanceof Date) return JSON.stringify(value.toISOString()); + if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stable(entry)}`).join(",")}}`; + } + return JSON.stringify(value); +} + +export function adminIntentFingerprint(intent: AdminIntent): string { + return createHash("sha256").update(stable(intent)).digest("hex"); +} + +/** + * A target retains every applied operation ID: A can be reconciled after B has + * changed the target, without mistaking B's newer state for A's failed write. + */ +export class AdminOperations { + constructor(private readonly db: Db) {} + + async inspect(id: string): Promise { + try { + return await this.db.collection("admin_operations").findOne({ _id: id }, { timeoutMS: 5_000 }); + } catch (error) { + console.error("Unable to read security operation intent", id, error); + throw new AccessError(503, "intent_unavailable", "Security operation intent store unavailable"); + } + } + + async execute(intent: AdminIntent): Promise { + const operations = this.db.collection("admin_operations"); + const fingerprint = adminIntentFingerprint(intent); + let operation = await this.inspect(intent._id); + if (!operation) { + try { + const { initial, condition, ...required } = intent; + operation = { ...required, ...(initial ? { initial } : {}), ...(condition ? { condition } : {}), fingerprint, state: "prepared", createdAt: new Date() }; + await operations.insertOne(operation, { timeoutMS: 5_000 }); + } catch (error) { + if (!(error instanceof MongoServerError) || error.code !== 11000) { + console.error("Unable to persist security operation intent", intent._id, error); + throw new AccessError(503, "intent_unavailable", "Security operation intent could not be persisted"); + } + operation = await this.inspect(intent._id); + } + } + if (!operation || operation.fingerprint !== fingerprint) { + throw new AccessError(409, "idempotency_conflict", "Operation ID was already used for different input"); + } + if (operation.state === "conflict") { + throw new AccessError(409, "revision_conflict", "Target revision or preconditions changed"); + } + const revision = operation.expectedRevision + 1; + if (operation.state === "completed") return { operationId: operation._id, state: "completed", revision }; + const target = this.db.collection(operation.collection); + try { + let current = await target.findOne({ _id: operation.targetId }, { timeoutMS: 5_000 }); + if (!Array.isArray(current?.appliedOperationIds) || !current.appliedOperationIds.includes(operation._id)) { + const set = { + ...Object.fromEntries(Object.entries(operation.change).map(([key, value]) => + [key, value === "$operationTime" ? operation.createdAt : value])), + revision, + lastOperationId: operation._id, + updatedAt: new Date(), + }; + if (!current && operation.initial && operation.expectedRevision === 0) { + try { + await target.insertOne({ + ...operation.initial, ...set, _id: operation.targetId, + createdAt: operation.createdAt, appliedOperationIds: [operation._id], + }, { timeoutMS: 5_000 }); + } catch (error) { + if (!(error instanceof MongoServerError) || error.code !== 11000) throw error; + } + } else { + const filter: Filter = { + _id: operation.targetId, + ...operation.condition, + ...(operation.expectedRevision === 0 + ? { $or: [{ revision: 0 }, { revision: { $exists: false } }] } + : { revision: operation.expectedRevision }), + }; + await target.updateOne(filter, { + $set: set, + $addToSet: { appliedOperationIds: operation._id }, + ...(operation.unset.length ? { $unset: Object.fromEntries(operation.unset.map(key => [key, ""])) } : {}), + }, { timeoutMS: 5_000 }); + } + current = await target.findOne({ _id: operation.targetId }, { timeoutMS: 5_000 }); + if (!Array.isArray(current?.appliedOperationIds) || !current.appliedOperationIds.includes(operation._id)) { + await operations.updateOne({ _id: operation._id, state: "prepared" }, { + $set: { state: "conflict", completedAt: new Date() }, + }, { timeoutMS: 5_000 }); + throw new AccessError(409, "revision_conflict", "Target revision or preconditions changed"); + } + } + if (operation.creatorMembershipOperationId) { + try { + const membership = await this.execute({ + _id: operation.creatorMembershipOperationId, + actor: operation.actor, reason: "Project creator project-admin membership", + collection: "project_memberships", targetId: `${operation.targetId}:${operation.actor}`, + expectedRevision: 0, change: { role: "admin" }, unset: [], + initial: { projectId: operation.targetId, userId: operation.actor, createdByUserId: operation.actor }, + }); + if (membership.state !== "completed") return { operationId: operation._id, state: "pending", revision }; + } catch (error) { + console.error("Project creation awaits creator membership reconciliation", operation._id, error); + return { operationId: operation._id, state: "pending", revision }; + } + } + const completion = await operations.updateOne({ _id: operation._id, state: "prepared" }, { + $set: { state: "completed", completedAt: new Date() }, + }, { timeoutMS: 5_000 }); + if (!completion.acknowledged || (completion.matchedCount !== 1 && + !await operations.findOne({ _id: operation._id, fingerprint, state: "completed" }, { timeoutMS: 5_000 }))) { + throw new Error("Security operation completion was not durably confirmed"); + } + return { operationId: operation._id, state: "completed", revision }; + } catch (error) { + if (error instanceof AccessError) throw error; + console.error("Security operation needs reconciliation", operation._id, error); + return { operationId: operation._id, state: "pending", revision }; + } + } + + async reconcile(id: string): Promise { + const operation = await this.inspect(id); + if (!operation) throw new AccessError(404, "not_found", "Operation not found"); + const { fingerprint: _fingerprint, state: _state, createdAt: _createdAt, completedAt: _completedAt, ...intent } = operation; + return this.execute(intent); + } + + async isComplete(marker: unknown, timeoutMS = 5_000): Promise { + if (marker === undefined) return true; + if (typeof marker !== "string") return false; + return Boolean(await this.db.collection("admin_operations") + .findOne({ _id: marker, state: "completed" }, { projection: { _id: 1 }, timeoutMS })); + } +} diff --git a/apps/api/src/auth/auth-flow.integration.test.ts b/apps/api/src/auth/auth-flow.integration.test.ts index 98cd01516..bd29d2bcb 100644 --- a/apps/api/src/auth/auth-flow.integration.test.ts +++ b/apps/api/src/auth/auth-flow.integration.test.ts @@ -3,16 +3,19 @@ import { generateKeyPairSync, randomUUID, sign } from "node:crypto"; import { setTimeout as delay } from "node:timers/promises"; -import { MongoClient, type Collection } from "mongodb"; +import { MongoClient, type Collection, type Db } 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 { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { EntraIdAuthProvider, ProjectStore, type ProjectDocument, type UserDocument } from "shared"; +import { AccessCapabilitiesSchema, PlatformAdminListSchema, type AccessUserSearchResult, type ProjectMembershipSummary } from "shared/rbac"; 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"; +import { AdminOperations, type AdminOperation } from "./admin-operations.js"; +import { operatorAccess } from "./operator-access.js"; vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: [], pending: [] }), @@ -38,6 +41,7 @@ describe.runIf(Boolean(mongoUri && redisPort))("IdP -> explicit login -> Redis/M const database = `auth-test-${randomUUID()}`; const keys = generateKeyPairSync("rsa", { modulusLength: 2048 }); let mongo: MongoClient; + let db: Db; let users: Collection; let inspector: Redis; let cache: RedisUserAccessCache; @@ -65,20 +69,20 @@ describe.runIf(Boolean(mongoUri && redisPort))("IdP -> explicit login -> Redis/M if (!mongoUri) throw new Error("AUTH_TEST_MONGO_URI is required"); mongo = new MongoClient(mongoUri); await mongo.connect(); - users = mongo.db(database).collection("users"); + db = mongo.db(database); + users = db.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]), - }); + store = new UserStore(users); resolver = new UserAccessResolver({ userStore: store, cache, enricher: null }); _injectTestDependencies(createAllMockDependencies()); _injectTestDependencies({ + db, + usersCollection: users, authProvider: new EntraIdAuthProvider({ authority: "https://login.microsoftonline.com/common", audience: "scope-api", @@ -109,6 +113,24 @@ describe.runIf(Boolean(mongoUri && redisPort))("IdP -> explicit login -> Redis/M await mongo.close(); } }); + afterEach(() => vi.restoreAllMocks()); + + async function administrationFixture() { + _injectTestDependencies({ projectStore: new ProjectStore(db.collection("projects")) }); + const adminSubject = randomUUID(), userSubject = randomUUID(); + const adminBearer = `Bearer ${token(adminSubject)}`, userBearer = `Bearer ${token(userSubject)}`; + const admin = await request(app).post("/api/v1/users/me").set("Authorization", adminBearer); + const user = await request(app).post("/api/v1/users/me").set("Authorization", userBearer); + expect(admin.status).toBe(200); + expect(user.status).toBe(200); + const adminId: string = admin.body.id, userId: string = user.body.id; + await operatorAccess(db, { + database, confirmDatabase: database, idp: "entra", tenant, subject: adminSubject, + userId: adminId, revision: 0, role: "admin", disabled: "false", operator: "native-promotion-test", + reason: "Isolated platform administrator fixture", operationId: randomUUID(), apply: true, + }); + return { adminId, userId, adminBearer, userBearer }; + } it("enrolls only on login, caches role, and performs read-only lookup after expiry", async () => { const bearer = `Bearer ${token()}`; @@ -119,11 +141,21 @@ describe.runIf(Boolean(mongoUri && redisPort))("IdP -> explicit login -> Redis/M 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.role).toBe("user"); expect(login.body.id).not.toBe(subject); - const stored = await users.findOne({ idpSubject: subject }); + let stored = await users.findOne({ idpSubject: subject }); if (!stored) throw new Error("Login did not persist the user"); expect(login.body.id).toBe(stored._id); + await operatorAccess(db, { + database, confirmDatabase: database, idp: "entra", tenant, subject, userId: stored._id, + role: "admin", disabled: "false", revision: 0, operationId: randomUUID(), + operator: "isolated-auth-test", reason: "Explicit enrolled-user bootstrap", bootstrap: true, apply: true, + }); + const refreshed = await request(app).post("/api/v1/users/me").set("Authorization", bearer); + expect(refreshed.status).toBe(200); + expect(refreshed.body.role).toBe("admin"); + stored = await users.findOne({ idpSubject: subject }); + if (!stored) throw new Error("Bootstrapped user disappeared"); const lastLogin = stored.lastLoginAt?.getTime(); expect(lastLogin).toBeTypeOf("number"); @@ -171,6 +203,210 @@ describe.runIf(Boolean(mongoUri && redisPort))("IdP -> explicit login -> Redis/M vi.restoreAllMocks(); }); + it("allows global admin catalogs without membership and retains member selection without administration", async () => { + const { adminId, userId, adminBearer, userBearer } = await administrationFixture(); + expect(await db.collection("project_memberships").countDocuments({ userId: adminId })).toBe(0); + for (const path of ["/api/v1/agents", "/api/v1/models"]) { + expect((await request(app).get(path).set("Authorization", adminBearer)).status).toBe(200); + expect((await request(app).get(path).set("Authorization", userBearer)).status).toBe(403); + expect((await request(app).get(path)).status).toBe(401); + } + const project = await request(app).post("/api/v1/projects") + .set("Authorization", adminBearer).send({ name: "Catalog selection fixture" }); + expect(project.status).toBe(201); + const projectId: string = project.body.id; + let revision = 0; + for (const role of ["reader", "contributor", "admin"]) { + const grant = await request(app).put(`/api/v1/projects/${projectId}/members/${userId}`) + .set("Authorization", adminBearer).send({ role, revision }); + expect(grant.status).toBe(200); + revision++; + for (const path of ["/api/v1/agents", "/api/v1/models"]) { + expect((await request(app).get(path).set("Authorization", userBearer)).status).toBe(200); + } + for (const path of ["/api/v1/agents", "/api/v1/models/sync", "/api/v1/keys", "/api/v1/accounts"]) { + expect((await request(app).post(path).set("Authorization", userBearer).send({})).status).toBe(403); + } + } + const revoke = await request(app).delete(`/api/v1/projects/${projectId}/members/${userId}`) + .set("Authorization", adminBearer).send({ revision }); + expect(revoke.status).toBe(200); + for (const path of ["/api/v1/agents", "/api/v1/models"]) { + expect((await request(app).get(path).set("Authorization", userBearer)).status).toBe(403); + } + }); + + describe("Platform administrator HTTP operations", () => { + it("lists only platform administrators with server-side pagination and no identity secrets", async () => { + const { adminBearer, userBearer, userId } = await administrationFixture(); + const disabledAdmin = await store.upsertOnLogin({ idp: "entra", idpTenant: tenant, idpSubject: randomUUID() }, { displayName: "Disabled administrator fixture" }); + await new AdminOperations(db).execute({ + _id: randomUUID(), actor: "test-operator", reason: "Disabled administrator roster fixture", + collection: "users", targetId: disabledAdmin._id, expectedRevision: 0, + change: { role: "admin", disabledAt: "$operationTime" }, unset: [], + }); + expect((await request(app).get("/api/v1/admin/platform-admins")).status).toBe(401); + expect((await request(app).get("/api/v1/admin/platform-admins").set("Authorization", "Bearer invalid")).status).toBe(401); + expect((await request(app).get("/api/v1/admin/platform-admins").set("Authorization", userBearer)).status).toBe(403); + const ids: string[] = []; + let cursor: string | undefined; + do { + const response = await request(app).get("/api/v1/admin/platform-admins") + .query({ limit: 1, ...(cursor ? { cursor } : {}) }).set("Authorization", adminBearer); + expect(response.status).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + const page = PlatformAdminListSchema.parse(response.body); + expect(page.items.length).toBeLessThanOrEqual(1); + for (const admin of page.items) { + expect(admin.role).toBe("admin"); + for (const key of Object.keys(admin)) { + expect(["id", "displayName", "email", "alias", "role", "disabled", "revision", "pending", "operationId"]).toContain(key); + } + if (admin.id === disabledAdmin._id) expect(admin).toMatchObject({ disabled: true, pending: false }); + ids.push(admin.id); + } + cursor = page.nextCursor; + } while (cursor); + expect(ids).toEqual((await users.find({ role: "admin" }).sort({ _id: 1 }).toArray()).map(user => user._id)); + expect(ids).not.toContain(userId); + expect(new Set(ids).size).toBe(ids.length); + for (const query of [{ limit: 0 }, { limit: 51 }, { cursor: "" }, { role: "user" }]) { + expect((await request(app).get("/api/v1/admin/platform-admins").query(query).set("Authorization", adminBearer)).status).toBe(400); + } + }); + + it("promotes exactly once through HTTP, refreshes canonical capabilities and grants no project membership", async () => { + const { adminId, adminBearer, userId, userBearer } = await administrationFixture(); + const path = `/api/v1/admin/users/${userId}/promote`; + const id = randomUUID(); + expect((await request(app).post(path).send({ revision: 0 })).status).toBe(401); + expect((await request(app).post(path).set("Authorization", userBearer).send({ revision: 0 })).status).toBe(403); + const promote = () => request(app).post(path).set("Authorization", adminBearer).set("Idempotency-Key", id).send({ revision: 0 }); + const result = await promote(); + expect(result.status).toBe(200); + expect(result.body).toEqual({ state: "completed", operationId: id, revision: 1 }); + expect((await promote()).body).toEqual(result.body); + expect(await users.findOne({ _id: userId })).toMatchObject({ + role: "admin", revision: 1, lastOperationId: id, appliedOperationIds: [id], + }); + expect(await db.collection("admin_operations").findOne({ _id: id })).toMatchObject({ + actor: adminId, targetId: userId, state: "completed", expectedRevision: 0, + collection: "users", change: { role: "admin" }, condition: { role: "user", disabledAt: { $exists: false } }, + }); + const me = await request(app).get("/api/v1/users/me").set("Authorization", userBearer); + expect(me.status).toBe(200); + expect(me.body.capabilities).toMatchObject({ platformAdmin: true, memberships: [] }); + expect(await db.collection("project_memberships").countDocuments({ userId })).toBe(0); + expect((await request(app).get("/api/v1/agents").set("Authorization", userBearer)).status).toBe(200); + expect((await request(app).get("/api/v1/models").set("Authorization", userBearer)).status).toBe(200); + const project = await request(app).post("/api/v1/projects") + .set("Authorization", adminBearer).send({ name: "No catalog content bypass" }); + expect(project.status).toBe(201); + expect((await request(app).get("/api/v1/requests").query({ projectId: project.body.id }) + .set("Authorization", userBearer)).status).toBe(404); + expect((await request(app).post(`/api/v1/admin/users/${userId}/disable`).set("Authorization", adminBearer).send({ revision: 1 })).status).toBe(403); + const reused = await request(app).post(path).set("Authorization", adminBearer).set("Idempotency-Key", id).send({ revision: 1 }); + expect(reused.status).toBe(409); + expect(reused.body.code).toBe("idempotency_conflict"); + + await new AdminOperations(db).execute({ + _id: randomUUID(), actor: "test-operator", reason: "Later demotion fixture", + collection: "users", targetId: userId, expectedRevision: 1, change: { role: "user" }, unset: [], + }); + expect((await promote()).body).toEqual(result.body); + expect(await users.findOne({ _id: userId })).toMatchObject({ role: "user", revision: 2 }); + }); + + it("rejects disabled, missing, already-admin, stale and arbitrary-role targets without elevating them", async () => { + const { adminId, adminBearer, userId } = await administrationFixture(); + const promote = (id: string, body: unknown) => request(app).post(`/api/v1/admin/users/${id}/promote`) + .set("Authorization", adminBearer).send(body); + for (const body of [{}, { revision: -1 }, { revision: 1.5 }, { revision: "0" }, { revision: 0, role: "user" }, { revision: 0, disabled: false }]) { + expect((await promote(userId, body)).status).toBe(400); + } + expect((await promote(randomUUID(), { revision: 0 })).status).toBe(404); + expect((await promote(adminId, { revision: 1 })).status).toBe(409); + expect((await promote(userId, { revision: 4 })).status).toBe(409); + await users.updateOne({ _id: userId }, { $set: { disabledAt: new Date() } }); + expect((await promote(userId, { revision: 0 })).status).toBe(409); + expect(await users.findOne({ _id: userId })).toMatchObject({ role: "user", disabledAt: expect.any(Date) }); + }); + + it("does not let a project admin elevate platform roles and only accepts one competing promotion", async () => { + const { adminBearer, userId, userBearer } = await administrationFixture(); + const created = await request(app).post("/api/v1/projects").set("Authorization", adminBearer).send({ name: "Promotion boundary" }); + expect(created.status).toBe(201); + const projectId: string = created.body.id; + expect((await request(app).put(`/api/v1/projects/${projectId}/members/${userId}`) + .set("Authorization", adminBearer).send({ role: "admin", revision: 0 })).status).toBe(200); + const path = `/api/v1/admin/users/${userId}/promote`; + expect((await request(app).post(path).set("Authorization", userBearer).send({ revision: 0 })).status).toBe(403); + expect((await request(app).get("/api/v1/admin/platform-admins").set("Authorization", userBearer)).status).toBe(403); + const results = await Promise.all([randomUUID(), randomUUID()].map(id => + request(app).post(path).set("Authorization", adminBearer).set("Idempotency-Key", id).send({ revision: 0 }))); + expect(results.map(result => result.status).sort()).toEqual([200, 409]); + expect(await users.findOne({ _id: userId })).toMatchObject({ role: "admin", revision: 1 }); + }); + + it("withholds a partially completed promotion and recovers with the identical HTTP operation", async () => { + const { adminBearer, userId, userBearer } = await administrationFixture(); + const id = randomUUID(); + const operations = db.collection("admin_operations"); + const originalCollection = db.collection.bind(db); + vi.spyOn(db, "collection").mockImplementation(name => name === "admin_operations" ? operations : originalCollection(name)); + const update = operations.updateOne.bind(operations); + const completion = vi.spyOn(operations, "updateOne").mockImplementation(async (...args) => { + if (args[0]._id === id) throw new Error("Promotion completion store unavailable"); + return update(...args); + }); + const promote = () => request(app).post(`/api/v1/admin/users/${userId}/promote`) + .set("Authorization", adminBearer).set("Idempotency-Key", id).send({ revision: 0 }); + expect((await promote()).status).toBe(202); + expect(await users.findOne({ _id: userId })).toMatchObject({ role: "admin", revision: 1, lastOperationId: id }); + const pendingUser = await request(app).get("/api/v1/users/me").set("Authorization", userBearer); + expect(pendingUser.status).toBe(503); + expect(pendingUser.body.code).toBe("operation_pending"); + const roster = await request(app).get("/api/v1/admin/platform-admins").set("Authorization", adminBearer); + expect(PlatformAdminListSchema.parse(roster.body).items.find(user => user.id === userId)).toMatchObject({ pending: true, operationId: id }); + completion.mockRestore(); + const recovered = await promote(); + expect(recovered.status).toBe(200); + expect(recovered.body).toEqual({ operationId: id, state: "completed", revision: 1 }); + expect(await users.findOne({ _id: userId })).toMatchObject({ revision: 1, appliedOperationIds: [id] }); + expect((await request(app).get("/api/v1/users/me").set("Authorization", userBearer)).body.capabilities.platformAdmin).toBe(true); + }); + + it("does not mutate on intent failure or overwrite another pending user-security operation", async () => { + const { adminBearer, userId } = await administrationFixture(); + const operations = db.collection("admin_operations"); + const originalCollection = db.collection.bind(db); + vi.spyOn(db, "collection").mockImplementation(name => name === "admin_operations" ? operations : originalCollection(name)); + vi.spyOn(operations, "insertOne").mockRejectedValueOnce(new Error("Intent store unavailable")); + const path = `/api/v1/admin/users/${userId}/promote`; + expect((await request(app).post(path).set("Authorization", adminBearer).send({ revision: 0 })).status).toBe(503); + expect(await users.findOne({ _id: userId })).toMatchObject({ role: "user" }); + const pendingId = randomUUID(); + await users.updateOne({ _id: userId }, { $set: { revision: 1, lastOperationId: pendingId } }); + const blocked = await request(app).post(path).set("Authorization", adminBearer).send({ revision: 1 }); + expect(blocked.status).toBe(409); + expect(blocked.body.code).toBe("operation_pending"); + expect(await users.findOne({ _id: userId })).toMatchObject({ role: "user", revision: 1, lastOperationId: pendingId }); + }); + + it("rechecks the enabled-state precondition at the final promotion write", async () => { + const { adminBearer, userId } = await administrationFixture(); + const execute = AdminOperations.prototype.execute; + vi.spyOn(AdminOperations.prototype, "execute").mockImplementation(async function (this: AdminOperations, intent) { + await users.updateOne({ _id: userId }, { $set: { disabledAt: new Date() } }); + return execute.call(this, intent); + }); + const result = await request(app).post(`/api/v1/admin/users/${userId}/promote`) + .set("Authorization", adminBearer).send({ revision: 0 }); + expect(result.status).toBe(409); + expect(await users.findOne({ _id: userId })).toMatchObject({ role: "user", disabledAt: expect.any(Date) }); + }); + }); + it("uses MongoDB and admits no missing users when the cache is unavailable", async () => { const otherSubject = randomUUID(); const bearer = `Bearer ${token(otherSubject)}`; @@ -187,4 +423,123 @@ describe.runIf(Boolean(mongoUri && redisPort))("IdP -> explicit login -> Redis/M expect((await users.findOne({ idpSubject: otherSubject }))?.lastLoginAt).toEqual(before?.lastLoginAt); expect(await users.countDocuments({ idpSubject: otherSubject })).toBe(1); }); + + it("keeps global search platform-only while verified project admins search enrolled membership candidates", async () => { + const db = mongo.db(database); + _injectTestDependencies({ projectStore: new ProjectStore(db.collection("projects")) }); + const adminSubject = randomUUID(), ordinarySubject = randomUUID(); + const adminBearer = `Bearer ${token(adminSubject)}`, ordinaryBearer = `Bearer ${token(ordinarySubject)}`; + const enrolled = await request(app).post("/api/v1/users/me").set("Authorization", adminBearer); + expect(enrolled.status).toBe(200); + const adminId: string = enrolled.body.id; + await operatorAccess(db, { + database, confirmDatabase: database, idp: "entra", tenant, subject: adminSubject, + userId: adminId, revision: 0, role: "admin", disabled: "false", operator: "native-access-test", + reason: "Isolated administrative directory fixture", operationId: randomUUID(), apply: true, + }); + const ordinary = await request(app).post("/api/v1/users/me").set("Authorization", ordinaryBearer); + expect(ordinary.status).toBe(200); + const ordinaryId: string = ordinary.body.id; + const prefix = `Directory fixture ${randomUUID()}`; + const fixtures = await Promise.all(["Alice", "Bob.*[", "Charlie"].map(name => store.upsertOnLogin({ + idp: "entra", idpTenant: tenant, idpSubject: randomUUID(), + }, { displayName: `${prefix} ${name}`, email: `${name}@example.test`, emailVerified: true }))); + + expect((await request(app).get("/api/v1/admin/users/search")).status).toBe(401); + expect((await request(app).get("/api/v1/admin/users/search").set("Authorization", ordinaryBearer)).status).toBe(403); + const first = await request(app).get("/api/v1/admin/users/search").query({ q: prefix.toUpperCase(), limit: 2 }).set("Authorization", adminBearer); + expect(first.status).toBe(200); + const firstPage: AccessUserSearchResult = first.body; + expect(firstPage.items).toHaveLength(2); + expect(firstPage.nextCursor).toBeTruthy(); + const second = await request(app).get("/api/v1/admin/users/search") + .query({ q: prefix, limit: 2, cursor: firstPage.nextCursor }).set("Authorization", adminBearer); + expect(second.status).toBe(200); + const secondPage: AccessUserSearchResult = second.body; + expect(secondPage.items).toHaveLength(1); + expect(secondPage.nextCursor).toBeUndefined(); + expect(new Set([...firstPage.items, ...secondPage.items].map(user => user.id))).toEqual(new Set(fixtures.map(user => user._id))); + for (const user of firstPage.items) { + expect(Object.keys(user).sort()).toEqual(["alias", "disabled", "displayName", "email", "id", "revision", "role"]); + } + const literal = await request(app).get("/api/v1/admin/users/search").query({ q: ".*[" }).set("Authorization", adminBearer); + expect(literal.status).toBe(200); + const matches: AccessUserSearchResult = literal.body; + expect(matches.items.map(user => user.id)).toEqual([fixtures[1]._id]); + for (const query of [{ limit: 0 }, { limit: 51 }, { q: "x".repeat(101) }, { cursor: "" }]) { + expect((await request(app).get("/api/v1/admin/users/search").query(query).set("Authorization", adminBearer)).status).toBe(400); + } + const project = await request(app).post("/api/v1/projects").set("Authorization", adminBearer).send({ name: "Directory access fixture" }); + expect(project.status).toBe(201); + const projectId: string = project.body.id; + const grant = await request(app).put(`/api/v1/projects/${projectId}/members/${ordinaryId}`) + .set("Authorization", adminBearer).send({ role: "admin", revision: 0 }); + expect(grant.status).toBe(200); + expect((await request(app).get("/api/v1/admin/users/search").set("Authorization", ordinaryBearer)).status).toBe(403); + expect((await request(app).get(`/api/v1/projects/${projectId}/members`).set("Authorization", ordinaryBearer)).status).toBe(200); + const searchPath = `/api/v1/projects/${projectId}/members/search`; + expect((await request(app).get(searchPath)).status).toBe(401); + expect((await request(app).get(searchPath).set("Authorization", "Bearer invalid")).status).toBe(401); + const scopedFirst = await request(app).get(searchPath) + .query({ q: prefix.toUpperCase(), limit: 2 }).set("Authorization", ordinaryBearer); + expect(scopedFirst.status).toBe(200); + expect(scopedFirst.headers["cache-control"]).toBe("no-store"); + const scopedFirstPage: AccessUserSearchResult = scopedFirst.body; + const scopedSecond = await request(app).get(searchPath) + .query({ q: prefix, limit: 2, cursor: scopedFirstPage.nextCursor }).set("Authorization", ordinaryBearer); + expect(scopedSecond.status).toBe(200); + const scopedSecondPage: AccessUserSearchResult = scopedSecond.body; + expect(new Set([...scopedFirstPage.items, ...scopedSecondPage.items].map(user => user.id))) + .toEqual(new Set(fixtures.map(user => user._id))); + const foreign = await request(app).post("/api/v1/projects").set("Authorization", adminBearer).send({ name: "Foreign directory fixture" }); + expect(foreign.status).toBe(201); + expect((await request(app).get(`/api/v1/projects/${foreign.body.id}/members/search`) + .set("Authorization", ordinaryBearer)).status).toBe(404); + const candidate = fixtures[0]; + expect((await request(app).get(`/api/v1/projects/${projectId}/members/lookup/${candidate._id}`) + .set("Authorization", ordinaryBearer)).status).toBe(200); + const assignmentId = randomUUID(); + const assignment = await request(app).put(`/api/v1/projects/${projectId}/members/${candidate._id}`) + .set("Authorization", ordinaryBearer).set("Idempotency-Key", assignmentId).send({ role: "contributor", revision: 0 }); + expect(assignment.status).toBe(200); + expect((await request(app).get(searchPath).set("Authorization", `Bearer ${await token(candidate.idpSubject)}`)).status).toBe(403); + expect(await db.collection("admin_operations").findOne({ _id: assignmentId })).toMatchObject({ + actor: ordinaryId, state: "completed", reason: "Explicit project-admin membership assignment", + }); + const roster = await request(app).get(`/api/v1/projects/${projectId}/members`).set("Authorization", adminBearer); + expect(roster.status).toBe(200); + const members: ProjectMembershipSummary[] = roster.body; + expect(members.find(member => member.userId === ordinaryId)).toMatchObject({ + role: "admin", revision: 1, pending: false, user: { id: ordinaryId, displayName: "Integration User", disabled: false }, + }); + const disabled = fixtures[2]; + await new AdminOperations(db).execute({ + _id: randomUUID(), actor: adminId, reason: "Disabled directory fixture", + collection: "users", targetId: disabled._id, expectedRevision: 0, + change: { disabledAt: "$operationTime" }, unset: [], condition: { role: "user" }, + }); + expect((await request(app).put(`/api/v1/projects/${projectId}/members/${disabled._id}`) + .set("Authorization", adminBearer).send({ role: "reader", revision: 0 })).status).toBe(409); + const disabledSearch = await request(app).get("/api/v1/admin/users/search") + .query({ q: disabled._id }).set("Authorization", adminBearer); + const disabledResult: AccessUserSearchResult = disabledSearch.body; + expect(disabledResult.items[0]).toMatchObject({ id: disabled._id, disabled: true }); + const exact = await request(app).get("/api/v1/admin/users").query({ userId: ordinaryId }).set("Authorization", adminBearer); + expect(exact.status).toBe(200); + expect(exact.body[0].id).toBe(ordinaryId); + const capabilities = async () => { + const me = await request(app).get("/api/v1/users/me").set("Authorization", ordinaryBearer); + expect(me.status).toBe(200); + return AccessCapabilitiesSchema.parse(me.body.capabilities); + }; + expect((await capabilities()).memberships.find(member => member.projectId === projectId)?.permissions).toContain("members:write"); + const selfPath = `/api/v1/projects/${projectId}/members/${ordinaryId}`; + expect((await request(app).put(selfPath).set("Authorization", ordinaryBearer).send({ role: "reader", revision: 1 })).status).toBe(200); + expect((await capabilities()).memberships.find(member => member.projectId === projectId)?.permissions).not.toContain("members:write"); + expect((await request(app).get(`/api/v1/projects/${projectId}/members`).set("Authorization", ordinaryBearer)).status).toBe(403); + expect((await request(app).put(selfPath).set("Authorization", adminBearer).send({ role: "admin", revision: 2 })).status).toBe(200); + expect((await request(app).delete(selfPath).set("Authorization", ordinaryBearer).send({ revision: 3 })).status).toBe(200); + expect((await capabilities()).memberships.some(member => member.projectId === projectId)).toBe(false); + expect((await request(app).get(`/api/v1/projects/${projectId}/members`).set("Authorization", ordinaryBearer)).status).toBe(404); + }); }); diff --git a/apps/api/src/auth/auth-mode.integration.test.ts b/apps/api/src/auth/auth-mode.integration.test.ts new file mode 100644 index 000000000..bc45d8830 --- /dev/null +++ b/apps/api/src/auth/auth-mode.integration.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { MongoClient, type Db } from "mongodb"; +import request from "supertest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { ProjectStore, type ProjectDocument } from "shared"; +import { app, _injectTestDependencies } from "../index.js"; +import { createAllMockDependencies } from "../test-helpers.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(), +})); + +const uri = process.env.AUTH_TEST_MONGO_URI; +describe.runIf(Boolean(uri))("Explicit anonymous deployment mode with native Mongo", () => { + const database = `auth-mode-test-${randomUUID()}`; + let client: MongoClient; + let db: Db; + + beforeAll(async () => { + if (!uri) throw new Error("AUTH_TEST_MONGO_URI is required"); + client = new MongoClient(uri); + await client.connect(); + db = client.db(database); + _injectTestDependencies({ + ...createAllMockDependencies(), db, authEnabled: false, + projectStore: new ProjectStore(db.collection("projects")), + authProvider: null, userAccessResolver: null, + }); + }); + afterEach(() => _injectTestDependencies({ authEnabled: false })); + afterAll(async () => { + _injectTestDependencies({ authEnabled: true, authProvider: null, userAccessResolver: null }); + await db?.dropDatabase(); + await client?.close(); + }); + + async function create(name: string, id = randomUUID()) { + const response = await request(app).post("/api/v1/projects").set("Idempotency-Key", id).send({ name }); + expect(response.status).toBe(201); + return response; + } + + it("publishes the effective mode and exposes catalogs without an identity", async () => { + const version = await request(app).get("/api/v1/version"); + expect(version.status).toBe(200); + expect(version.body.authEnabled).toBe(false); + for (const path of ["/agents", "/models", "/feature-flags"]) { + expect((await request(app).get(`/api/v1${path}`)).status).toBe(200); + } + expect((await request(app).get("/api/v1/projects").set("Authorization", "Bearer invalid")).status).toBe(200); + expect(await db.collection("users").countDocuments()).toBe(0); + }); + + it("creates, lists, edits, deletes and restores without creating a user or membership", async () => { + const key = randomUUID(); + const created = await create("Anonymous lifecycle", key); + const id: string = created.body.id; + expect((await create("Anonymous lifecycle", key)).body.id).toBe(id); + const list = await request(app).get("/api/v1/projects"); + expect(list.body).toEqual(expect.arrayContaining([expect.objectContaining({ id })])); + const edited = await request(app).patch(`/api/v1/projects/${id}`).send({ name: "Renamed anonymously", revision: 1 }); + expect(edited.status).toBe(200); + expect(edited.body.revision).toBe(2); + expect((await request(app).delete(`/api/v1/projects/${id}`).send({ revision: 2 })).status).toBe(204); + expect((await request(app).get(`/api/v1/projects/${id}`)).status).toBe(404); + const deleted = await request(app).get("/api/v1/projects?includeDeleted=true"); + expect(deleted.body).toEqual(expect.arrayContaining([expect.objectContaining({ id, deletedAt: expect.any(String) })])); + const restored = await request(app).post(`/api/v1/projects/${id}/restore`).send({ revision: 3 }); + expect(restored.status).toBe(200); + expect(restored.body.deletedAt).toBeUndefined(); + expect(restored.body.revision).toBe(4); + expect(await db.collection("users").countDocuments()).toBe(0); + expect(await db.collection("project_memberships").countDocuments()).toBe(0); + expect(await db.collection("admin_operations").findOne({ _id: key })).toMatchObject({ + actor: "anonymous", state: "completed", reason: "Anonymous project creation (authentication disabled)", + }); + }); + + it("still validates project scope, document relationships and project existence", async () => { + const first = await create("First scope"); + const second = await create("Second scope"); + const foreignId = randomUUID(); + await db.collection("criteria").insertOne({ _id: foreignId, projectId: first.body.id }); + const list = await request(app).get(`/api/v1/criteria?projectId=${first.body.id}`); + expect(list.status).toBe(200); + expect((await request(app).get("/api/v1/criteria")).status).toBe(400); + expect((await request(app).get(`/api/v1/criteria?projectId=${randomUUID()}`)).status).toBe(404); + expect((await request(app).get(`/api/v1/criteria/${foreignId}?projectId=${second.body.id}`)).status).toBe(404); + expect((await request(app).get("/api/v1/criteria/slug-without-project")).status).toBe(400); + }); + + it("does not permit enrollment, directory lookup, membership writes or operator recovery through HTTP", async () => { + for (const method of ["get", "post"] as const) { + expect((await request(app)[method]("/api/v1/users/me")).status).toBe(401); + } + for (const path of ["/admin/users", "/admin/users/search", "/admin/platform-admins", "/projects/fixture/members"]) { + const response = await request(app).get(`/api/v1${path}`); + expect(response.status).toBe(503); + expect(response.body.code).toBe("authentication_disabled"); + } + const write = await request(app).put("/api/v1/projects/fixture/members/fixture-user").send({ role: "admin", revision: 0 }); + expect(write.status).toBe(503); + const promotion = await request(app).post("/api/v1/admin/users/fixture-user/promote").send({ revision: 0 }); + expect(promotion.status).toBe(503); + expect(promotion.body.code).toBe("authentication_disabled"); + expect(await db.collection("project_memberships").countDocuments()).toBe(0); + }); + + it("keeps report generation disabled and makes zero enqueue calls", async () => { + const deps = createAllMockDependencies(); + _injectTestDependencies({ reportQueueClient: deps.reportQueueClient }); + for (const path of ["/reports", "/reports/bulk-create", "/reports/trigger", "/reports/bulk-trigger", "/reports/fixture/insights"]) { + const response = await request(app).post(`/api/v1${path}`).send({}); + expect(response.status).toBe(503); + expect(response.body.code).toBe("report_generation_disabled"); + } + expect(deps.reportQueueClient?.sendMessage).not.toHaveBeenCalled(); + }); + + it("re-enables enforcement without granting memberships or altering stored projects", async () => { + const project = await create("Re-enable fixture"); + const before = await db.collection("projects").find({}).toArray(); + _injectTestDependencies({ authEnabled: true }); + expect((await request(app).get("/api/v1/version")).body.authEnabled).toBe(true); + expect((await request(app).get("/api/v1/projects")).status).toBe(401); + expect((await request(app).get(`/api/v1/projects/${project.body.id}`)).status).toBe(401); + expect(await db.collection("projects").find({}).toArray()).toEqual(before); + expect(await db.collection("project_memberships").countDocuments()).toBe(0); + }); +}); diff --git a/apps/api/src/auth/authorization.test.ts b/apps/api/src/auth/authorization.test.ts new file mode 100644 index 000000000..92f68d37e --- /dev/null +++ b/apps/api/src/auth/authorization.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { performance } from "node:perf_hooks"; +import express from "express"; +import request from "supertest"; +import { MongoClient } from "mongodb"; +import { UserDocumentSchema, type ProjectMembership } from "shared"; +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { apiRoute } from "../openapi/api-route.js"; +import { Authorization, protectStream, routePolicy } from "./authorization.js"; +import { AccessError } from "./admin-operations.js"; + +afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); + +describe("Explicit HTTP route classification", () => { + it("fails registration for unclassified endpoints", () => { + expect(() => routePolicy("get", "/api/v1/unclassified")).toThrow("Unclassified API route"); + }); + it("keeps enrollment, control plane, metadata and project data distinct", () => { + expect(routePolicy("post", "/api/v1/users/me")).toBe("self"); + expect(routePolicy("get", "/api/v1/admin/users")).toBe("platform"); + expect(routePolicy("get", "/api/v1/admin/users/search")).toBe("platform"); + expect(routePolicy("get", "/api/v1/admin/platform-admins")).toBe("platform"); + expect(routePolicy("post", "/api/v1/admin/users/:id/promote")).toBe("platform"); + expect(routePolicy("get", "/api/v1/agents")).toBe("metadata"); + expect(routePolicy("post", "/api/v1/agents")).toBe("platform"); + expect(routePolicy("get", "/api/v1/projects")).toBe("projects"); + expect(routePolicy("get", "/api/v1/resources/revisions/:id")).toBe("project"); + }); + it.each(["/reports", "/reports/bulk-create", "/reports/trigger", "/reports/bulk-trigger", "/reports/:id/insights"])( + "blocks %s before any handler or enqueue operation", async path => { + const app = express(); + const enqueue = vi.fn(); + apiRoute(app, new OpenAPIRegistry(), { + method: "post", path: `/api/v1${path}`, tags: ["Reports"], summary: "Cutover fixture", + response: z.unknown(), handler: (_req, res) => { enqueue(); res.json({}); }, + }); + app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error instanceof AccessError ? error.status : 500).json({ error: error.message }); + }); + expect((await request(app).post(`/api/v1${path.replace(":id", "fixture")}`).send({})).status).toBe(503); + expect(enqueue).not.toHaveBeenCalled(); + }); + it("keeps stored-report reads and read-only bulk status available for project authorization", () => { + expect(routePolicy("get", "/api/v1/reports/:id")).toBe("project"); + expect(routePolicy("post", "/api/v1/reports/bulk-status")).toBe("project"); + expect(routePolicy("post", "/api/v1/reports/bulk-summary")).toBe("project"); + }); + it("fails closed before controller execution when authorization has not initialized", async () => { + const app = express(); + const controller = vi.fn(); + apiRoute(app, new OpenAPIRegistry(), { + method: "get", path: "/api/v1/criteria", tags: ["Criteria"], summary: "Unavailable authority", + response: z.unknown(), handler: (_req, res) => { controller(); res.json([]); }, + }); + app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error instanceof AccessError ? error.status : 500).json({ error: error.message }); + }); + expect((await request(app).get("/api/v1/criteria")).status).toBe(503); + expect(controller).not.toHaveBeenCalled(); + }); +}); + +describe("Canonical authority work budget", () => { + describe("Project membership administration", () => { + function fixture(role: "reader" | "contributor" | "admin", platformAdmin = false) { + const authority = new Authorization(new MongoClient("mongodb://127.0.0.1:1").db("unused_unit_fixture")); + vi.spyOn(authority, "currentUser").mockResolvedValue(UserDocumentSchema.parse({ + _id: "actor", role: platformAdmin ? "admin" : "user", idp: "entra", idpTenant: "fixture", idpSubject: "fixture", + createdAt: new Date(), updatedAt: new Date(), + })); + const membership: ProjectMembership = { + _id: "a:actor", projectId: "a", userId: "actor", role, revision: 1, + createdAt: new Date(), updatedAt: new Date(), createdByUserId: "operator", + lastOperationId: "grant", appliedOperationIds: ["grant"], + }; + const memberships = vi.spyOn(authority, "memberships").mockResolvedValue([membership]); + const req = Object.assign(Object.create(express.request) as express.Request, { + params: { id: "a", userId: "target" }, query: {}, body: {}, + auth: { token: `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 300 })).toString("base64url")}.signature` }, + }); + return { authority, memberships, membership, req }; + } + it.each(["get", "put", "delete"])("allows a current project admin to %s only their own membership surface", async method => { + const { authority, req } = fixture("admin"); + const path = `/api/v1/projects/:id/members${method === "get" ? "" : "/:userId"}`; + await expect(authority.guard(req, method, path, "projects")).resolves.toBeTypeOf("number"); + req.params.id = "b"; + await expect(authority.guard(req, method, path, "projects")).rejects.toMatchObject({ status: 404 }); + }); + it.each(["reader", "contributor"] as const)("denies the in-project %s roster, search, lookup and mutation", async role => { + const { authority, req } = fixture(role); + for (const [method, suffix] of [["get", ""], ["get", "/search"], ["get", "/lookup/:userId"], ["put", "/:userId"], ["delete", "/:userId"]]) { + await expect(authority.guard(req, method, `/api/v1/projects/:id/members${suffix}`, "projects")) + .rejects.toMatchObject({ status: 403 }); + } + }); + it("rereads membership on the next request after demotion or removal", async () => { + const { authority, req, membership, memberships } = fixture("admin"); + const path = "/api/v1/projects/:id/members"; + await expect(authority.guard(req, "get", path, "projects")).resolves.toBeTypeOf("number"); + memberships.mockResolvedValue([{ ...membership, role: "reader" }]); + await expect(authority.guard(req, "get", path, "projects")).rejects.toMatchObject({ status: 403 }); + memberships.mockResolvedValue([]); + await expect(authority.guard(req, "get", path, "projects")).rejects.toMatchObject({ status: 404 }); + }); + it("does not grant project admins directory, inventory, lifecycle or global platform powers", async () => { + const { authority, req } = fixture("admin"); + for (const path of ["/api/v1/admin/users/search", "/api/v1/admin/users", "/api/v1/admin/users/:id/promote", "/api/v1/keys", "/api/v1/accounts"]) { + await expect(authority.guard(req, "get", path, "platform")).rejects.toMatchObject({ status: 403 }); + } + req.query.controlPlane = "true"; + await expect(authority.guard(req, "get", "/api/v1/projects", "projects")).rejects.toMatchObject({ status: 403 }); + for (const [method, path] of [["post", "/api/v1/projects"], ["delete", "/api/v1/projects/:id"], ["post", "/api/v1/projects/:id/restore"]]) { + await expect(authority.guard(req, method, path, "projects")).rejects.toMatchObject({ status: 403 }); + } + }); + it("retains platform administration without project membership", async () => { + const { authority, req, memberships } = fixture("reader", true); + memberships.mockResolvedValue([]); + for (const method of ["get", "put", "delete"]) { + await expect(authority.guard(req, method, "/api/v1/projects/:id/members/:userId", "projects")).resolves.toBeTypeOf("number"); + } + expect(memberships).not.toHaveBeenCalled(); + }); + }); + it("passes one original deadline through identity and membership reads", async () => { + let now = 0; + vi.spyOn(performance, "now").mockImplementation(() => now); + const authority = new Authorization(new MongoClient("mongodb://127.0.0.1:1").db("unused_unit_fixture")); + const user = UserDocumentSchema.parse({ + _id: "fixture-user", idp: "entra", idpTenant: "fixture", idpSubject: "fixture", + createdAt: new Date(), updatedAt: new Date(), + }); + vi.spyOn(authority, "currentUser").mockImplementation(async (_req, deadline) => { + expect(deadline).toBe(20_000); + now = 19_000; + return user; + }); + vi.spyOn(authority, "memberships").mockImplementation(async (_userId, deadline) => { + expect(deadline).toBe(20_000); + now = 20_001; + return []; + }); + await expect(authority.capabilities(express.request)).rejects.toMatchObject({ code: "authority_timeout" }); + }); + + describe("Explicit authentication opt-out", () => { + const authority = () => new Authorization(new MongoClient("mongodb://127.0.0.1:1").db("unused_unit_fixture"), false); + + it.each(["/api/v1/admin/users", "/api/v1/admin/platform-admins", "/api/v1/admin/users/:id/promote", + "/api/v1/projects/:id/members", "/api/v1/projects/:id/members/:userId"])( + "does not open identity or membership administration at %s", async path => { + await expect(authority().guard(express.request, "get", path, routePolicy("get", path))) + .rejects.toMatchObject({ code: "authentication_disabled" }); + }, + ); + it.each(["/api/v1/projects", "/api/v1/agents", "/api/v1/models", "/api/v1/feature-flags"])( + "allows legacy application access at %s without reading users or memberships", async path => { + const auth = authority(); + const current = vi.spyOn(auth, "currentUser"); + const memberships = vi.spyOn(auth, "memberships"); + await expect(auth.guard(express.request, "get", path, routePolicy("get", path))).resolves.toBeTypeOf("number"); + expect(current).not.toHaveBeenCalled(); + expect(memberships).not.toHaveBeenCalled(); + }, + ); + it("does not enable report producers when auth is disabled", async () => { + await expect(authority().guard(express.request, "post", "/api/v1/reports", "disabled")) + .rejects.toMatchObject({ code: "report_generation_disabled" }); + }); + }); + + it("does not start a new membership query after its deadline expires", async () => { + vi.spyOn(performance, "now").mockReturnValue(20_001); + const authority = new Authorization(new MongoClient("mongodb://127.0.0.1:1").db("unused_unit_fixture")); + await expect(authority.memberships("fixture-user", 20_000)).rejects.toMatchObject({ code: "authority_timeout" }); + }); +}); + +describe("Open stream freshness leases", () => { + it("closes at the original deadline during a hung authority read and refuses subsequent writes", async () => { + vi.useFakeTimers(); + let now = 0; + vi.spyOn(performance, "now").mockImplementation(() => now); + const originalWrite = vi.fn(() => true); + const res: Parameters[0] = { write: originalWrite, end: vi.fn(), once: vi.fn() }; + protectStream(res, () => new Promise(() => {}), 20000); + now = 19999; + await vi.advanceTimersByTimeAsync(19000); + expect(res.end).not.toHaveBeenCalled(); + now = 20000; + await vi.advanceTimersByTimeAsync(1000); + expect(res.end).toHaveBeenCalled(); + expect(res.write("not permitted")).toBe(false); + expect(originalWrite).not.toHaveBeenCalled(); + }); + it("closes on the first denied authority recheck rather than retaining a warm grant", async () => { + vi.useFakeTimers(); + vi.spyOn(performance, "now").mockReturnValue(0); + const res: Parameters[0] = { write: vi.fn(() => true), end: vi.fn(), once: vi.fn() }; + protectStream(res, async () => { throw new AccessError(404, "not_found", "Not found"); }, 20000); + await vi.advanceTimersByTimeAsync(1000); + expect(res.end).toHaveBeenCalledOnce(); + expect(res.write("forbidden")).toBe(false); + }); + it("never outlives the token-capped initial deadline", async () => { + vi.useFakeTimers(); + vi.spyOn(performance, "now").mockReturnValue(2000); + const res: Parameters[0] = { write: vi.fn(() => true), end: vi.fn(), once: vi.fn() }; + protectStream(res, async () => 1000, 1000); + await vi.advanceTimersByTimeAsync(1000); + expect(res.end).toHaveBeenCalled(); + }); + it("cannot be revived by a successful authority result arriving after closure", async () => { + vi.useFakeTimers(); + let now = 0; + vi.spyOn(performance, "now").mockImplementation(() => now); + let complete: ((deadline: number) => void) | undefined; + const res: Parameters[0] = { write: vi.fn(() => true), end: vi.fn(), once: vi.fn() }; + protectStream(res, () => new Promise(resolve => { complete = resolve; }), 20_000); + await vi.advanceTimersByTimeAsync(1000); + now = 20_000; + await vi.advanceTimersByTimeAsync(19_000); + expect(res.end).toHaveBeenCalledOnce(); + if (!complete) throw new Error("Expected an in-flight authority recheck"); + complete(60_000); + await Promise.resolve(); + expect(res.write("late result must not grant access")).toBe(false); + expect(res.end).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/api/src/auth/authorization.ts b/apps/api/src/auth/authorization.ts new file mode 100644 index 000000000..bb172290b --- /dev/null +++ b/apps/api/src/auth/authorization.ts @@ -0,0 +1,307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { performance } from "node:perf_hooks"; +import type { Db, Document } from "mongodb"; +import type { Request, Response } from "express"; +import { ANONYMOUS_USER_ID, MembershipSchema, projectPermissions, parseProfileSpec, parseExtensionSpec, type AccessCapabilities, type ProjectPermission, type ProjectMembership, type UserDocument } from "shared"; +import { AccessError, AdminOperations } from "./admin-operations.js"; + +export type RoutePolicy = "public" | "self" | "platform" | "metadata" | "projects" | "project" | "disabled"; +const publicPaths = new Set(["/health", "/ready", "/about", "/api/v1/version"]); +const collections: Readonly> = { + requests: "requests", reports: "reports", insights: "insights", profiles: "profiles", + criteria: "criteria", "prompt-features": "prompt-features", "task-prompts": "task-prompts", + skills: "skills", "skill-revisions": "skill-revisions", codebases: "codebases", + "codebase-revisions": "codebase-revisions", resources: "resources", + extensions: "extensions", "report-templates": "report-templates", "mcp/servers": "mcp-servers", +}; +const pointParams = ["id", "profileId", "ref"]; +const readOnlyPosts = new Set(["/api/v1/reports/bulk-status", "/api/v1/reports/bulk-summary", "/api/v1/requests/archive"]); +const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const LEASE_MS = 20_000; + +export function routePolicy(method: string, path: string): RoutePolicy { + if (publicPaths.has(path)) return "public"; + if (path === "/api/v1/users/me") return "self"; + if (path.startsWith("/api/v1/admin/")) return "platform"; + if (/^\/api\/v1\/(keys|accounts|feature-flags)(\/|$)/.test(path)) return "platform"; + if (/^\/api\/v1\/(agents|models)(\/|$)/.test(path)) return method === "get" ? "metadata" : "platform"; + if (path.startsWith("/api/v1/projects")) return "projects"; + if (path.startsWith("/api/v1/reports") && method !== "get" && !readOnlyPosts.has(path)) return "disabled"; + if (Object.keys(collections).some(root => path === `/api/v1/${root}` || path.startsWith(`/api/v1/${root}/`)) || + ["/api/v1/analysis", "/api/v1/runs/upload", "/api/v1/runs/upload-batch"].includes(path)) return "project"; + throw new Error(`Unclassified API route: ${method.toUpperCase()} ${path}`); +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export class Authorization { + readonly operations: AdminOperations; + constructor(readonly db: Db, readonly enabled = true) { this.operations = new AdminOperations(db); } + + private readOptions(deadline: number): { timeoutMS: number } { + const remaining = deadline - performance.now(); + if (remaining <= 0) throw new AccessError(503, "authority_timeout", "Authorization freshness budget exceeded"); + return { timeoutMS: Math.max(1, Math.min(5_000, Math.floor(remaining))) }; + } + + async currentUser(req: Request, deadline = performance.now() + LEASE_MS): Promise { + if (!req.auth || !req.user?.isAuthenticated) throw new AccessError(401, "authentication_required", "Authentication required"); + const user = await this.db.collection("users") + .findOne({ _id: req.user.id }, this.readOptions(deadline)); + if (!user || user.disabledAt) throw new AccessError(403, "access_denied", "User is missing or disabled"); + if (user.role !== "user" && user.role !== "admin") { + console.error("Invalid platform role", user._id); + throw new AccessError(503, "authority_invalid", "Authorization authority is invalid"); + } + if (!await this.operations.isComplete(user.lastOperationId, this.readOptions(deadline).timeoutMS)) throw new AccessError(503, "operation_pending", "Access change is pending operator repair"); + return user; + } + + async memberships(userId: string, deadline = performance.now() + LEASE_MS): Promise { + const records = await this.db.collection("project_memberships") + .find({ userId, revokedAt: { $exists: false } }, this.readOptions(deadline)).toArray(); + const valid: ProjectMembership[] = []; + for (const raw of records) { + if (performance.now() >= deadline) throw new AccessError(503, "authority_timeout", "Authorization freshness budget exceeded"); + const parsed = MembershipSchema.safeParse(raw); + if (!parsed.success) { + console.error("Invalid project membership", raw._id); + throw new AccessError(503, "authority_invalid", "Authorization authority is invalid"); + } + const project = await this.db.collection("projects") + .findOne({ _id: parsed.data.projectId, deletedAt: { $exists: false } }, this.readOptions(deadline)); + if (project && await this.operations.isComplete(project.lastOperationId, this.readOptions(deadline).timeoutMS) && + await this.operations.isComplete(parsed.data.lastOperationId, this.readOptions(deadline).timeoutMS)) valid.push(parsed.data); + } + return valid; + } + + async capabilities(req: Request): Promise { + return this.withBudget(deadline => this.readCapabilities(req, deadline)); + } + + private async readCapabilities(req: Request, deadline: number): Promise { + const user = await this.currentUser(req, deadline); + const memberships = await this.memberships(user._id, deadline); + this.readOptions(deadline); + return { + platformAdmin: user.role === "admin", reportsEnabled: false, + memberships: memberships.map(m => ({ + projectId: m.projectId, role: m.role, permissions: projectPermissions(m.role), revision: m.revision, + })), + }; + } + + async requireProject(userId: string, projectId: string, permission: ProjectPermission, deadline = performance.now() + LEASE_MS): Promise { + if (!this.enabled) { + const project = await this.db.collection("projects") + .findOne({ _id: projectId, deletedAt: { $exists: false } }, this.readOptions(deadline)); + if (!project) throw new AccessError(404, "not_found", "Not found"); + return; + } + const memberships = await this.memberships(userId, deadline); + const membership = memberships.find(m => m.projectId === projectId); + if (!membership) throw new AccessError(404, "not_found", "Not found"); + if (!projectPermissions(membership.role).includes(permission)) { + throw new AccessError(403, "insufficient_permission", "Insufficient project permission"); + } + } + + private async scopedDocument(collection: string, id: string, projectId: string | undefined, deadline: number): Promise { + const query: Record = uuid.test(id) ? { _id: id } : + projectId ? { projectId, $or: [{ _id: id }, { id }, { slug: id }, { ref: id }] } : {}; + if (!Object.keys(query).length) throw new AccessError(400, "project_required", "A projectId is required for a non-UUID identifier"); + if (projectId) query.projectId = projectId; + const doc = await this.db.collection(collection) + .findOne(query, this.readOptions(deadline)); + if (!doc || !text(doc.projectId)) throw new AccessError(404, "not_found", "Not found"); + return doc; + } + + async guard(req: Request, method: string, path: string, policy: RoutePolicy): Promise { + return this.withBudget(deadline => this.decide(req, method, path, policy, deadline)); + } + + private async withBudget(read: (deadline: number) => Promise): Promise { + const deadline = performance.now() + LEASE_MS; + let timer: ReturnType | undefined; + try { + return await Promise.race([read(deadline), new Promise((_, reject) => { + timer = setTimeout(() => reject(new AccessError(503, "authority_timeout", "Authorization freshness budget exceeded")), LEASE_MS); + timer.unref(); + })]); + } catch (error) { + if (error instanceof AccessError) throw error; + console.error("Authorization authority unavailable", error); + throw new AccessError(503, "authority_unavailable", "Authorization authority unavailable"); + } finally { if (timer) clearTimeout(timer); } + } + + private async decide(req: Request, method: string, path: string, policy: RoutePolicy, budgetDeadline: number): Promise { + const started = budgetDeadline - LEASE_MS; + if (policy === "disabled") throw new AccessError(503, "report_generation_disabled", "New report generation is disabled"); + if (policy === "public" || policy === "self") return started + LEASE_MS; + if (!this.enabled) { + if (path.startsWith("/api/v1/admin/") || /^\/api\/v1\/projects\/[^/]+\/members(?:\/|$)/.test(path)) { + throw new AccessError(503, "authentication_disabled", "Access administration requires authentication to be enabled"); + } + if (policy === "project") await this.projectRequest(req, method, path, req.user?.id ?? ANONYMOUS_USER_ID, budgetDeadline); + return started + LEASE_MS; + } + try { + const user = await this.currentUser(req, budgetDeadline); + if (policy === "platform") { + if (user.role !== "admin") throw new AccessError(403, "insufficient_permission", "Platform administration required"); + } else if (policy === "metadata") { + if (user.role !== "admin" && !(await this.memberships(user._id, budgetDeadline)).length) throw new AccessError(403, "membership_required", "An active project membership is required"); + } else if (policy === "projects") { + const membership = /^\/api\/v1\/projects\/:id\/members(?:\/|$)/.test(path); + const control = !membership && (method === "delete" || method === "post" || + (path === "/api/v1/projects" && req.query.controlPlane === "true")); + if (control && user.role !== "admin") throw new AccessError(403, "insufficient_permission", "Platform administration required"); + if (!control && req.params.id && user.role !== "admin") { + await this.requireProject(user._id, req.params.id, membership ? "members:write" : method === "get" ? "content:read" : "settings:write", budgetDeadline); + } + } else { + await this.projectRequest(req, method, path, user._id, budgetDeadline); + } + // The unchanged bytes have already passed the IdP verifier. + const claims: unknown = req.auth + ? JSON.parse(Buffer.from(req.auth.token.split(".")[1], "base64url").toString("utf8")) : null; + const exp = record(claims).exp; + const remaining = typeof exp === "number" ? exp * 1000 - Date.now() : 0; + const deadline = Math.min(started + LEASE_MS, performance.now() + remaining); + if (deadline <= performance.now()) throw new AccessError(503, "authority_timeout", "Authorization could not be validated within its freshness budget"); + return deadline; + } catch (error) { + if (error instanceof AccessError) throw error; + console.error("Authorization authority unavailable", error); + throw new AccessError(503, "authority_unavailable", "Authorization authority unavailable"); + } + } + + private async projectRequest(req: Request, method: string, path: string, userId: string, deadline: number): Promise { + let projectId = text(req.query.projectId); + const body = record(req.body); + const root = Object.keys(collections).find(key => path === `/api/v1/${key}` || path.startsWith(`/api/v1/${key}/`)); + const identifier = pointParams.map(key => text(req.params[key])).find(Boolean)?.replace(/\/archive$/, ""); + if (root && identifier) { + const collection = path === "/api/v1/resources/revisions/:id" ? "resource-revisions" : collections[root]; + const doc = await this.scopedDocument(collection, identifier, projectId, deadline); + projectId = text(doc.projectId); + } + const requestId = text(req.query.requestId) ?? text(body.requestId); + if (requestId) { + const request = await this.scopedDocument("requests", requestId, projectId, deadline); + projectId = text(request.projectId); + } + const sourceReportId = text(body.sourceReportId); + if (sourceReportId) { + const report = await this.scopedDocument("reports", sourceReportId, projectId, deadline); + projectId = text(report.projectId); + } + if (!projectId) throw new AccessError(400, "project_required", "A projectId is required"); + if (text(body.projectId) && body.projectId !== projectId) throw new AccessError(404, "not_found", "Not found"); + const permission = method === "get" || method === "head" || readOnlyPosts.has(path) + ? "content:read" : method === "delete" ? "content:delete" : "content:write"; + await this.requireProject(userId, projectId, permission, deadline); + req.authorizedProjectId = projectId; + // Bulk identifiers and referenced documents cannot widen the authorized scope. + for (const key of ["ids", "requestIds", "reportIds"]) { + const ids = body[key]; + if (Array.isArray(ids)) for (const id of ids) { + if (typeof id !== "string") throw new AccessError(400, "invalid_reference", "Invalid reference"); + await this.scopedDocument(key === "reportIds" ? "reports" : key === "requestIds" ? "requests" : collections[root ?? "requests"] ?? "requests", id, projectId, deadline); + } + } + await this.references(body, projectId, deadline); + } + + private async references(body: Record, projectId: string, deadline: number): Promise { + this.readOptions(deadline); + const refs: Readonly> = { + profileId: "profiles", taskPromptId: "task-prompts", promptId: "task-prompts", + agentsMdPromptId: "task-prompts", insightId: "insights", codebaseId: "codebases", + codebaseRevisionId: "codebase-revisions", resourceId: "resources", sourceReportId: "reports", + }; + for (const [key, collection] of Object.entries(refs)) { + const spec = text(body[key]); + if (!spec) continue; + let id = spec; + if (key === "profileId") { + try { id = parseProfileSpec(spec).profileId; } + catch { throw new AccessError(400, "invalid_reference", "Invalid profile reference"); } + } + await this.scopedDocument(collection, id, projectId, deadline); + } + const arrays: Readonly> = { + criteria: "criteria", criteriaIds: "criteria", dependsOn: "criteria", + taskPromptIds: "task-prompts", agentsMdParentIds: "task-prompts", + featureIds: "prompt-features", mcpServers: "mcp-servers", + skillRevisions: "skill-revisions", skills: "skills", profileVariations: "profiles", + extensions: "extensions", + }; + for (const [key, collection] of Object.entries(arrays)) { + const values = body[key]; + if (!Array.isArray(values)) continue; + for (const value of values) { + if (typeof value !== "string") throw new AccessError(400, "invalid_reference", "Invalid reference"); + let id = value; + if (key === "profileVariations") { + try { id = parseProfileSpec(value).profileId; } + catch { throw new AccessError(400, "invalid_reference", "Invalid profile reference"); } + } + if (key === "extensions") id = parseExtensionSpec(value).id; + await this.scopedDocument(collection, id, projectId, deadline); + } + } + const codebase = text(body.codebase); + if (codebase) await this.scopedDocument(uuid.test(codebase) || /@r\d+$/.test(codebase) ? "codebase-revisions" : "codebases", codebase, projectId, deadline); + if (Array.isArray(body.resources)) for (const value of body.resources) { + const spec = text(value) ?? text(record(value).revisionId) ?? text(record(value).ref); + if (!spec) throw new AccessError(400, "invalid_reference", "Invalid resource reference"); + await this.scopedDocument(uuid.test(spec) || /@r\d+$/.test(spec) ? "resource-revisions" : "resources", spec, projectId, deadline); + } + for (const key of ["scenario", "overrides"]) { + if (body[key] !== undefined) await this.references(record(body[key]), projectId, deadline); + } + if (Array.isArray(body.gates)) for (const gate of body.gates) await this.references(record(gate), projectId, deadline); + } +} + +type LeaseResponse = Pick & { end(): unknown; once(event: "close", listener: () => void): unknown }; + +export function protectStream(res: LeaseResponse, revalidate: () => Promise, initialDeadline: number): void { + let deadline = initialDeadline; + let checking = false; + let closed = false; + const stop = () => { + if (closed) return; + closed = true; + clearInterval(timer); + res.end(); + }; + const write = res.write.bind(res); + res.write = (chunk: unknown, encoding?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void): boolean => { + if (closed || performance.now() >= deadline) { stop(); return false; } + return typeof encoding === "string" ? write(chunk, encoding, callback) : write(chunk, encoding); + }; + const timer = setInterval(() => { + if (performance.now() >= deadline) { stop(); return; } + if (checking) return; + checking = true; + void revalidate().then(value => { if (!closed) deadline = value; }, error => { + console.warn("Closing unauthorized stream", error instanceof Error ? error.message : error); + stop(); + }).finally(() => { checking = false; }); + }, 1_000); + timer.unref(); + res.once("close", () => { closed = true; clearInterval(timer); }); +} diff --git a/apps/api/src/auth/legacy-memberships.ts b/apps/api/src/auth/legacy-memberships.ts new file mode 100644 index 000000000..03d6e99b8 --- /dev/null +++ b/apps/api/src/auth/legacy-memberships.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFile } from "node:fs/promises"; +import { parseArgs } from "node:util"; +import { pathToFileURL } from "node:url"; +import { MongoClient, type Db, type Document } from "mongodb"; +import { z } from "zod"; +import { ProjectRoleSchema } from "shared"; +import { AdminOperations, AccessError } from "./admin-operations.js"; + +export const LegacyMembershipManifest = z.object({ + database: z.string().min(1), reviewedBy: z.string().min(1), reviewedAt: z.string().datetime(), + reason: z.string().min(1), + entries: z.array(z.object({ + projectId: z.string().uuid(), userId: z.string().uuid(), role: ProjectRoleSchema, + revision: z.number().int().nonnegative(), operationId: z.string().min(1), + }).strict()), +}).strict().superRefine((manifest, ctx) => { + const pairs = new Set(); + const operations = new Set(); + for (const entry of manifest.entries) { + const pair = `${entry.projectId}:${entry.userId}`; + if (pairs.has(pair) || operations.has(entry.operationId)) ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Duplicate membership or operation ID" }); + pairs.add(pair); operations.add(entry.operationId); + } +}); + +export async function mapLegacyMemberships(db: Db, manifest: z.infer, apply: boolean) { + if (manifest.database !== db.databaseName) throw new AccessError(400, "database_mismatch", "Manifest database does not match the explicit target database"); + // Validate the entire mapping before the first possible grant. + for (const entry of manifest.entries) { + const project = await db.collection("projects").findOne({ _id: entry.projectId, deletedAt: { $exists: false } }, { timeoutMS: 5_000 }); + const user = await db.collection("users").findOne({ _id: entry.userId }, { timeoutMS: 5_000 }); + if (!project || !user) throw new AccessError(404, "not_found", "Manifest references a missing project or enrolled user"); + } + if (!apply) return { dryRun: true, entries: manifest.entries.length }; + const operations = new AdminOperations(db); + const results = []; + for (const entry of manifest.entries) { + const result = await operations.execute({ + _id: entry.operationId, actor: `reviewed-mapping:${manifest.reviewedBy}`, + reason: `${manifest.reason}; reviewed ${manifest.reviewedAt}`, + collection: "project_memberships", targetId: `${entry.projectId}:${entry.userId}`, + expectedRevision: entry.revision, change: { role: entry.role }, unset: ["revokedAt"], + initial: { projectId: entry.projectId, userId: entry.userId, createdByUserId: manifest.reviewedBy }, + }); + results.push(result); + if (result.state !== "completed") break; + } + return { dryRun: false, results }; +} + +async function main(): Promise { + const { values } = parseArgs({ options: { manifest: { type: "string" }, database: { type: "string" }, apply: { type: "boolean" }, "confirm-reviewer": { type: "string" } } }); + if (!values.manifest || !values.database) throw new Error("--manifest and --database are required (default is dry-run)"); + const raw: unknown = JSON.parse(await readFile(values.manifest, "utf8")); + const manifest = LegacyMembershipManifest.parse(raw); + if (values.apply && values["confirm-reviewer"] !== manifest.reviewedBy) throw new Error("--apply requires --confirm-reviewer matching the administrator-reviewed manifest"); + const uri = process.env.AUTH_OPERATOR_MONGO_URI; + if (!uri) throw new Error("AUTH_OPERATOR_MONGO_URI is required"); + const client = new MongoClient(uri, { serverSelectionTimeoutMS: 5_000 }); + try { + await client.connect(); + const result = await mapLegacyMemberships(client.db(values.database), manifest, Boolean(values.apply)); + console.log(JSON.stringify(result)); + if ("results" in result && result.results?.some(operation => operation.state !== "completed")) process.exitCode = 2; + } finally { await client.close(); } +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch(error => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); +} diff --git a/apps/api/src/auth/operator-access.test.ts b/apps/api/src/auth/operator-access.test.ts new file mode 100644 index 000000000..8d8d1342d --- /dev/null +++ b/apps/api/src/auth/operator-access.test.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { OperatorAccessInput } from "./operator-access.js"; + +const script = fileURLToPath(new URL("./operator-access.ts", import.meta.url)); +const target = [ + "--database", "operator-unit-fixture", "--confirm-database", "operator-unit-fixture", + "--operator", "reviewed-operator", "--reason", "Reviewed unit fixture", + "--idp", "entra", "--tenant", "fixture-tenant", "--subject", "fixture-subject", + "--user-id", "aaaaaaaa-0000-4000-8000-000000000001", +]; + +function command(...args: string[]) { + const env = { ...process.env }; + delete env.AUTH_OPERATOR_MONGO_URI; + return spawnSync(process.execPath, ["--import", "tsx", script, ...args], { encoding: "utf8", env, timeout: 15_000 }); +} + +describe("Operator access command boundary", () => { + it("prints all operator-only commands without database credentials", () => { + const result = command("--help"); + expect(result.status).toBe(0); + for (const name of ["bootstrap-admin", "set-user-access", "inspect-operation", "repair-operation"]) { + expect(result.stdout).toContain(name); + } + expect(result.stdout).toContain("default to dry run"); + }); + + it.each([ + { args: [], error: "Choose bootstrap-admin" }, + { args: ["bootstrap-admin", "--role", "user"], error: "does not accept role/disabled overrides" }, + { args: ["inspect-operation", "--apply"], error: "Inspection cannot apply changes" }, + { args: ["repair-operation", "--user-id", "target"], error: "user-target flags are not accepted" }, + { args: ["bootstrap-admin", "--apply", "--dry-run"], error: "mutually exclusive" }, + { args: ["bootstrap-admin", ...target, "--apply"], error: "--apply requires --revision and --operation-id" }, + { args: ["bootstrap-admin", ...target], error: "AUTH_OPERATOR_MONGO_URI is required" }, + ])("refuses invalid or unconfigured commands before connecting: $error", ({ args, error }) => { + const result = command(...args); + expect(result.status).toBe(1); + expect(result.stderr).toContain(error); + expect(result.stdout).toBe(""); + }); + + it("defaults validated bootstrap input to dry run and rejects arbitrary target fields", () => { + const input = { + database: "operator-unit-fixture", confirmDatabase: "operator-unit-fixture", + operator: "reviewed-operator", reason: "Reviewed unit fixture", + idp: "entra", tenant: "fixture-tenant", subject: "fixture-subject", + userId: "aaaaaaaa-0000-4000-8000-000000000001", role: "admin", disabled: "false", bootstrap: true, + }; + expect(OperatorAccessInput.parse(input)).toMatchObject({ apply: false }); + expect(() => OperatorAccessInput.parse({ ...input, email: "not-a-target@example.test" })).toThrow(); + expect(() => OperatorAccessInput.parse({ ...input, disabled: "true" })).toThrow(/Bootstrap can only promote/); + }); +}); diff --git a/apps/api/src/auth/operator-access.ts b/apps/api/src/auth/operator-access.ts new file mode 100644 index 000000000..30c0471b0 --- /dev/null +++ b/apps/api/src/auth/operator-access.ts @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { parseArgs } from "node:util"; +import { pathToFileURL } from "node:url"; +import { MongoClient, type Db, type Document } from "mongodb"; +import { z } from "zod"; +import { AdminOperations, AccessError, adminIntentFingerprint, type AdminIntent } from "./admin-operations.js"; + +const operatorInput = z.object({ + database: z.string().min(1), confirmDatabase: z.string().min(1), + operator: z.string().trim().min(1), reason: z.string().trim().min(1), + operationId: z.string().regex(/^[A-Za-z0-9:_-]{1,160}$/).optional(), + apply: z.boolean().default(false), +}); + +export const OperatorAccessInput = operatorInput.extend({ + idp: z.literal("entra"), tenant: z.string().min(1), subject: z.string().min(1), + userId: z.string().uuid(), revision: z.coerce.number().int().nonnegative().optional(), + role: z.enum(["admin", "user"]), disabled: z.enum(["true", "false"]), + bootstrap: z.boolean().default(false), +}).strict().superRefine((input, ctx) => { + if (input.apply && (input.revision === undefined || !input.operationId)) { + ctx.addIssue({ code: "custom", message: "--apply requires --revision and --operation-id" }); + } + if (input.bootstrap && (input.role !== "admin" || input.disabled !== "false")) { + ctx.addIssue({ code: "custom", message: "Bootstrap can only promote an enabled account" }); + } +}); + +export const OperatorOperationInput = operatorInput.extend({ + operationId: z.string().regex(/^[A-Za-z0-9:_-]{1,160}$/), +}).strict(); + +function confirmDatabase(db: Db, input: z.infer): void { + if (db.databaseName !== input.database || input.database !== input.confirmDatabase) { + throw new AccessError(400, "database_mismatch", "Explicit database confirmation does not match"); + } +} + +export async function operatorAccess(db: Db, rawInput: unknown) { + const input = OperatorAccessInput.parse(rawInput); + confirmDatabase(db, input); + const user = await db.collection("users").findOne({ + _id: input.userId, idp: input.idp, idpTenant: input.tenant, idpSubject: input.subject, + }, { timeoutMS: 5_000 }); + if (!user) throw new AccessError(404, "identity_mismatch", "Exact existing identity and Scope UUID not found"); + const currentRevision = z.number().int().nonnegative().parse(user.revision ?? 0); + const currentRole = z.enum(["admin", "user"]).parse(user.role); + if (input.bootstrap && user.disabledAt) throw new AccessError(409, "user_disabled", "Bootstrap cannot enable a disabled account; use an explicitly reviewed set-user-access change"); + const operations = new AdminOperations(db); + const prior = input.operationId ? await operations.inspect(input.operationId) : null; + const intent: AdminIntent = { + _id: input.operationId ?? "dry-run", actor: `operator:${input.operator}`, reason: input.reason, + collection: "users", targetId: user._id, expectedRevision: input.revision ?? currentRevision, + condition: { idp: input.idp, idpTenant: input.tenant, idpSubject: input.subject, + ...(input.bootstrap ? { role: "user", disabledAt: { $exists: false } } : {}) }, + change: { role: input.role, ...(input.disabled === "true" ? { disabledAt: "$operationTime" } : {}) }, + unset: input.disabled === "false" ? ["disabledAt"] : [], + backup: prior?.backup ?? user, + }; + if (prior && prior.fingerprint !== adminIntentFingerprint(intent)) { + throw new AccessError(409, "idempotency_conflict", "Operation ID was already used for different input"); + } + if (prior?.state === "conflict" || (!prior && input.revision !== undefined && input.revision !== currentRevision)) { + throw new AccessError(409, "revision_conflict", "Target revision or preconditions changed"); + } + if (!prior && !await operations.isComplete(user.lastOperationId)) { + throw new AccessError(409, "operation_pending", `Ask an operator to repair operation ${String(user.lastOperationId)} before another change`); + } + const summary = { + userId: user._id, currentRevision, currentRole, currentlyDisabled: Boolean(user.disabledAt), + requestedRole: input.role, requestedDisabled: input.disabled === "true", + }; + if (!input.apply) return { state: "dry-run" as const, ...summary }; + if (!prior && input.bootstrap && currentRole === "admin") { + return { state: "unchanged" as const, ...summary }; + } + return operations.execute(intent); +} + +export async function operatorOperation(db: Db, rawInput: unknown) { + const input = OperatorOperationInput.parse(rawInput); + confirmDatabase(db, input); + const operations = new AdminOperations(db); + const operation = await operations.inspect(input.operationId); + if (!operation) throw new AccessError(404, "not_found", "Operation not found"); + if (!input.apply) return { + state: "inspection" as const, operationId: operation._id, operationState: operation.state, + collection: operation.collection, targetId: operation.targetId, expectedRevision: operation.expectedRevision, + }; + const result = await operations.reconcile(input.operationId); + console.error(JSON.stringify({ + event: "operator-operation-repair", operator: input.operator, reason: input.reason, + database: input.database, operationId: input.operationId, state: result.state, + })); + return result; +} + +async function main(): Promise { + const { values, positionals } = parseArgs({ allowPositionals: true, options: { + database: { type: "string" }, "confirm-database": { type: "string" }, idp: { type: "string" }, + tenant: { type: "string" }, subject: { type: "string" }, "user-id": { type: "string" }, + revision: { type: "string" }, role: { type: "string" }, disabled: { type: "string" }, + operator: { type: "string" }, reason: { type: "string" }, "operation-id": { type: "string" }, + apply: { type: "boolean" }, "dry-run": { type: "boolean" }, help: { type: "boolean", short: "h" }, + } }); + const [command] = positionals; + if (values.help) { + console.log("Usage: operator-access.js \n" + + "All commands: --database DB --confirm-database DB --operator NAME --reason TEXT\n" + + "User commands: --idp entra --tenant TID --subject OID --user-id UUID\n" + + "set-user-access additionally requires --role admin|user --disabled true|false\n" + + "Inspection/repair: --operation-id ID\n" + + "Mutations default to dry run. Apply with --apply --operation-id ID; user changes also require --revision N.\n" + + "Use AUTH_OPERATOR_MONGO_URI from secure operator configuration. No authentication bypass or new account creation."); + return; + } + if (positionals.length !== 1 || !["bootstrap-admin", "set-user-access", "inspect-operation", "repair-operation"].includes(command)) { + throw new Error("Choose bootstrap-admin, set-user-access, inspect-operation or repair-operation; use --help"); + } + if (values.apply && values["dry-run"]) throw new Error("--apply and --dry-run are mutually exclusive"); + if (command === "inspect-operation" && values.apply) throw new Error("Inspection cannot apply changes; use repair-operation"); + const common = { + database: values.database, confirmDatabase: values["confirm-database"], + operator: values.operator, reason: values.reason, operationId: values["operation-id"], apply: values.apply ?? false, + }; + const operationCommand = command === "inspect-operation" || command === "repair-operation"; + if (operationCommand && [values.idp, values.tenant, values.subject, values["user-id"], values.revision, values.role, values.disabled].some(value => value !== undefined)) { + throw new Error("Operation commands use the recorded intent; user-target flags are not accepted"); + } + if (command === "bootstrap-admin" && (values.role !== undefined || values.disabled !== undefined)) { + throw new Error("Bootstrap does not accept role/disabled overrides; use set-user-access"); + } + const input = operationCommand ? OperatorOperationInput.parse(common) : OperatorAccessInput.parse({ + ...common, idp: values.idp, tenant: values.tenant, subject: values.subject, + userId: values["user-id"], revision: values.revision, + role: command === "bootstrap-admin" ? "admin" : values.role, + disabled: command === "bootstrap-admin" ? "false" : values.disabled, bootstrap: command === "bootstrap-admin", + }); + const uri = process.env.AUTH_OPERATOR_MONGO_URI; + if (!uri) throw new Error("AUTH_OPERATOR_MONGO_URI is required; use authenticated operator credentials outside local emulation"); + const client = new MongoClient(uri, { serverSelectionTimeoutMS: 5_000 }); + try { + await client.connect(); + const result = operationCommand + ? await operatorOperation(client.db(input.database), input) + : await operatorAccess(client.db(input.database), input); + console.log(JSON.stringify(result)); + if (result.state === "pending") process.exitCode = 2; + } finally { await client.close(); } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void main().catch(error => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); +} diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 16d570499..f6d350c94 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -13,9 +13,8 @@ export interface VerifiedAuthContext { /** * 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. + * This snapshot may come from the identity cache. Authorization rereads the + * canonical users/membership authority rather than trusting its cached role. */ export interface AuthenticatedUser { /** Scope User ID (UUID) for authenticated users, or "anonymous". */ @@ -40,6 +39,7 @@ declare global { interface Request { auth?: VerifiedAuthContext; user?: AuthenticatedUser; + authorizedProjectId?: string; } } } diff --git a/apps/api/src/auth/user-access-resolver.ts b/apps/api/src/auth/user-access-resolver.ts index c3e263727..5b9e2002f 100644 --- a/apps/api/src/auth/user-access-resolver.ts +++ b/apps/api/src/auth/user-access-resolver.ts @@ -76,6 +76,7 @@ export class UserAccessResolver implements UserAccessService { ? await this.options.enricher.enrich(identity, rawToken) : { email: identity.email, + ...(identity.alias !== undefined ? { alias: identity.alias } : {}), displayName: identity.displayName, emailVerified: identity.emailVerified, }; diff --git a/apps/api/src/auth/user-store.test.ts b/apps/api/src/auth/user-store.test.ts index 283936480..8a5a668ab 100644 --- a/apps/api/src/auth/user-store.test.ts +++ b/apps/api/src/auth/user-store.test.ts @@ -41,7 +41,7 @@ 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 findOne = vi.fn<(filter: unknown) => Promise>(async () => queue.shift() ?? null); const collection = { findOneAndUpdate, findOne } as unknown as Collection; return { collection, findOneAndUpdate, findOne }; @@ -91,6 +91,7 @@ describe("UserStore.upsertOnLogin", () => { expect(update.$set.displayName).toBe("Only Name"); expect("email" in update.$set).toBe(false); expect("emailVerified" in update.$set).toBe(false); + expect("alias" in update.$set).toBe(false); }); it("does not persist an email that is not verified", async () => { @@ -99,6 +100,7 @@ describe("UserStore.upsertOnLogin", () => { await store.upsertOnLogin(IDENTITY, { email: "unverified@example.com", + alias: "login-alias@example.com", emailVerified: false, }); @@ -107,125 +109,23 @@ describe("UserStore.upsertOnLogin", () => { }; expect("email" in update.$set).toBe(false); expect(update.$set.emailVerified).toBe(false); + expect(update.$set.alias).toBe("login-alias@example.com"); + expect(update.$set.role).toBeUndefined(); }); - 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"); + it.each(["user", "admin"])("preserves the existing %s role without any privilege write on login", async role => { + const { collection, findOneAndUpdate, findOne } = fakeCollection([baseDoc({ role })]); + expect(await new UserStore(collection).upsertOnLogin(IDENTITY, PROFILE)).toMatchObject({ role }); expect(findOneAndUpdate).toHaveBeenCalledOnce(); + expect(findOneAndUpdate.mock.calls[0][1]).not.toHaveProperty("$set.role"); + expect(findOne).not.toHaveBeenCalled(); }); - 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("does not promote a demoted user or remove an account disable marker", async () => { + const { collection, findOneAndUpdate } = fakeCollection([baseDoc({ role: "user", disabledAt: new Date() })]); + expect(await new UserStore(collection).upsertOnLogin(IDENTITY, PROFILE)).toMatchObject({ role: "user", disabledAt: expect.any(Date) }); + expect(findOneAndUpdate.mock.calls[0][1]).not.toHaveProperty("$set.role"); + expect(findOneAndUpdate.mock.calls[0][1]).not.toHaveProperty("$unset"); }); it("throws when the upsert returns nothing", async () => { diff --git a/apps/api/src/auth/user-store.ts b/apps/api/src/auth/user-store.ts index 06e032fe2..64b634d39 100644 --- a/apps/api/src/auth/user-store.ts +++ b/apps/api/src/auth/user-store.ts @@ -4,19 +4,11 @@ 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); } @@ -46,20 +38,10 @@ function isIdentityUpsertCollision(error: unknown, identity: VerifiedIdentity): * 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. + * explicitly verified. Login never changes an existing role or disabled state. */ 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(); - } + constructor(private readonly collection: Collection) {} /** JIT-upsert the user for a verified identity and return the stored record. */ async upsertOnLogin( @@ -75,6 +57,7 @@ export class UserStore { // 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.alias !== undefined || profile.email !== undefined) set.alias = profile.alias ?? profile.email; if (profile.emailVerified === true) { if (profile.email !== undefined) set.email = profile.email; set.emailVerified = true; @@ -121,21 +104,7 @@ export class UserStore { 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; + return result; } /** Read an existing identity without provisioning or refreshing login/profile fields. */ @@ -155,13 +124,4 @@ export class UserStore { 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/endpoints.test.ts b/apps/api/src/endpoints.test.ts index 9552f0696..f2e6b6702 100644 --- a/apps/api/src/endpoints.test.ts +++ b/apps/api/src/endpoints.test.ts @@ -1,15 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest"; import request from "supertest"; import { app, _injectTestDependencies } from "./index.js"; import { useTestServer } from "./test-server.js"; import { _resetRunFacetsCacheForTests } from "./routes/requests/index.js"; -import { createAllMockDependencies, createMockCollection } from "./test-helpers.js"; +import { createAllMockDependencies, createMockCollection, mockRouteAuthorization } from "./test-helpers.js"; const TEST_PROJECT_ID = "test-project"; +vi.mock("./auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("./test-helpers.js")).mockControllerUserAccessMiddleware, +})); + // Stub checkMigrations before it can be imported by index.ts vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ @@ -44,6 +49,7 @@ describe("API Endpoints", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { @@ -1095,7 +1101,7 @@ describe("API Endpoints", () => { }); describe("POST /api/v1/reports", () => { - it("returns 201 when creating a report", async () => { + it("blocks report creation before any write or enqueue", async () => { // Run exists (mocks.collection.findOne as any).mockResolvedValue({ _id: "r1", @@ -1106,27 +1112,30 @@ describe("API Endpoints", () => { .post("/api/v1/reports") .send({ requestId: "r1" }); - expect(res.status).toBe(201); - expect(res.body).toHaveProperty("requestId", "r1"); - expect(res.body).toHaveProperty("status", "pending"); + expect(res.status).toBe(503); + expect(res.body.code).toBe("report_generation_disabled"); + expect(mocks.reportCollection.insertOne).not.toHaveBeenCalled(); + expect(mocks.reportQueueClient.sendMessage).not.toHaveBeenCalled(); }); - it("returns 400 when requestId missing", async () => { + it("keeps report generation disabled even when requestId is missing", async () => { const res = await request(testServer()) .post("/api/v1/reports") .send({}); - expect(res.status).toBe(400); + expect(res.status).toBe(503); + expect(res.body.code).toBe("report_generation_disabled"); }); - it("returns 404 when referenced run not found", async () => { + it("does not disclose whether the disabled generation target exists", async () => { (mocks.collection.findOne as any).mockResolvedValue(null); const res = await request(testServer()) .post("/api/v1/reports") .send({ requestId: "no-such-run" }); - expect(res.status).toBe(404); + expect(res.status).toBe(503); + expect(mocks.collection.findOne).not.toHaveBeenCalled(); }); }); @@ -1572,6 +1581,93 @@ describe("API Endpoints", () => { }); }); + // =================================================================== + // AI context lookups are project-scoped (no cross-project LLM input) + // =================================================================== + + describe("AI endpoints scope LLM context to the project", () => { + afterEach(async () => { + (((await import("./task-prompt-llm.js")).isTaskPromptLlmAvailable) as any).mockReturnValue(false); + (((await import("./prompt-feature-llm.js")).isLlmAvailable) as any).mockReturnValue(false); + (((await import("./llm.js")).isLlmAvailable) as any).mockReturnValue(false); + }); + + it("POST /task-prompts/generate only reads the requested project's prompts", async () => { + const { isTaskPromptLlmAvailable, generateTaskPrompt } = await import("./task-prompt-llm.js"); + (isTaskPromptLlmAvailable as any).mockReturnValue(true); + (generateTaskPrompt as any).mockResolvedValue({ tasks: ["t"] }); + + const res = await request(testServer()) + .post(`/api/v1/task-prompts/generate?projectId=${TEST_PROJECT_ID}`) + .send({ description: "x" }); + + expect(res.status).toBe(200); + expect((mocks.taskPromptCollection.find as any).mock.calls[0][0]).toMatchObject({ projectId: TEST_PROJECT_ID }); + }); + + it("POST /task-prompts/generate rejects a missing projectId", async () => { + const { isTaskPromptLlmAvailable } = await import("./task-prompt-llm.js"); + (isTaskPromptLlmAvailable as any).mockReturnValue(true); + + const res = await request(testServer()).post("/api/v1/task-prompts/generate").send({ description: "x" }); + + expect(res.status).toBe(400); + expect(mocks.taskPromptCollection.find).not.toHaveBeenCalled(); + }); + + it("POST /task-prompts/:id/extract-features only reads the prompt's project features", async () => { + const { isLlmAvailable, extractPromptFeatures } = await import("./prompt-feature-llm.js"); + (isLlmAvailable as any).mockReturnValue(true); + (extractPromptFeatures as any).mockResolvedValue({ results: [], suggestedFeatures: [] }); + (mocks.taskPromptStore as any).get.mockResolvedValue({ _id: "tp-a", projectId: "project-a", text: "t", createdAt: new Date() }); + (mocks.taskPromptStore as any).resolvePromptText.mockResolvedValue("t"); + + const res = await request(testServer()).post("/api/v1/task-prompts/tp-a/extract-features?force=true").send({}); + + expect(res.status).toBe(200); + expect((mocks.promptFeatureCollection.find as any).mock.calls[0][0]).toMatchObject({ projectId: "project-a" }); + }); + + it("POST /prompt-features/generate-prompt and /extract-from-text filter by projectId", async () => { + const llm = await import("./prompt-feature-llm.js"); + (llm.isLlmAvailable as any).mockReturnValue(true); + (llm.generatePromptFeaturePrompt as any).mockResolvedValue({ prompt: "p" }); + (llm.extractPromptFeatures as any).mockResolvedValue({ results: [], suggestedFeatures: [] }); + + expect((await request(testServer()).post("/api/v1/prompt-features/generate-prompt").send({ behavior: "b" })).status).toBe(400); + expect((await request(testServer()).post("/api/v1/prompt-features/extract-from-text").send({ text: "t" })).status).toBe(400); + expect(mocks.promptFeatureCollection.find).not.toHaveBeenCalled(); + + await request(testServer()).post(`/api/v1/prompt-features/generate-prompt?projectId=${TEST_PROJECT_ID}`).send({ behavior: "b" }); + await request(testServer()).post(`/api/v1/prompt-features/extract-from-text?projectId=${TEST_PROJECT_ID}`).send({ text: "t" }); + const filters = (mocks.promptFeatureCollection.find as any).mock.calls.map((c: any[]) => c[0]); + expect(filters).toHaveLength(2); + for (const f of filters) expect(f).toMatchObject({ projectId: TEST_PROJECT_ID }); + }); + + it("POST /criteria/generate-prompt filters by projectId and requires it", async () => { + const llm = await import("./llm.js"); + (llm.isLlmAvailable as any).mockReturnValue(true); + (llm.generateCriteriaPrompt as any).mockResolvedValue({ prompt: "p", suggestedId: "s", suggestedParents: [], suggestedChildren: [] }); + + expect((await request(testServer()).post("/api/v1/criteria/generate-prompt").send({ behavior: "b" })).status).toBe(400); + const res = await request(testServer()).post(`/api/v1/criteria/generate-prompt?projectId=${TEST_PROJECT_ID}`).send({ behavior: "b" }); + expect(res.status).toBe(200); + expect((mocks.criteriaCollection.find as any).mock.calls.at(-1)[0]).toMatchObject({ projectId: TEST_PROJECT_ID }); + }); + + it("GET /criteria/mdp scopes the task-prompt feature lookup to the project", async () => { + (mocks.collection.find as any).mockReturnValue({ + project: vi.fn().mockReturnThis(), + toArray: vi.fn().mockResolvedValue([{ _id: "r1", scenario: {}, run: { status: "done" }, taskPromptId: "tp-1" }]), + }); + + const res = await request(testServer()).get(`/api/v1/criteria/mdp?projectId=${TEST_PROJECT_ID}`); + expect(res.status).toBe(200); + expect((mocks.taskPromptCollection.find as any).mock.calls[0][0]).toMatchObject({ projectId: TEST_PROJECT_ID }); + }); + }); + // =================================================================== // Submit with profile — server-side field resolution // =================================================================== diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index f35f6d0de..64a3999a0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -49,6 +49,10 @@ 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 { Authorization } from "./auth/authorization.js"; +import { resolveAuthEnabledFlag } from "shared/rbac"; +import { AccessError } from "./auth/admin-operations.js"; +import { registerAccessRoutes } from "./routes/access.js"; import type { RouteContext } from "./route-context.js"; import type { CriteriaDocument, @@ -77,7 +81,6 @@ const mcpSecretClient: McpSecretClient | null = TOKEN_MANAGER_URL const app: Express = express(); app.use(cors()); -app.use(express.json()); // Configuration from environment // K8s: MONGO_CONNECTION_STRING from secret, STORAGE_CONNECTION_STRING from secret @@ -90,6 +93,7 @@ const queueReport = process.env.AZURE_STORAGE_QUEUE_REPORT || "report-queue"; const strictAgentCapabilities = process.env.SCOPE_STRICT_AGENT_CAPABILITIES?.toLowerCase() === "true"; const port = parseInt(process.env.PORT || "3000", 10); +let authEnabled = resolveAuthEnabledFlag(process.env.SCOPE_AUTH_ENABLED); // MongoDB clients let mongoClient: MongoClient; @@ -121,6 +125,7 @@ let profileEnricher: ProfileEnricher | null = null; let userStore: UserStore | null = null; let userAccessCache: UserAccessCache | null = null; let userAccessResolver: UserAccessService | null = null; +let authorization: Authorization | null = null; let skillResolver: SkillResolver; let codebaseCollection: Collection; let codebaseRevisionCollection: Collection; @@ -170,14 +175,12 @@ async function initializeClients(): Promise { profileVersionCollection = db.collection("profile-versions"); usersCollection = db.collection("users"); + authorization = new Authorization(db, authEnabled); const authRuntime = loadAuthConfigFromEnv(); if (authRuntime) { authProvider = authRuntime.provider; profileEnricher = authRuntime.enricher; - userStore = new UserStore(usersCollection, { - bootstrapAdmins: authRuntime.bootstrapAdmins, - bootstrapTenants: authRuntime.bootstrapTenants, - }); + userStore = new UserStore(usersCollection); userAccessCache = new RedisUserAccessCache({ redisHost: process.env.REDIS_HOST || "", redisPort: parseInt(process.env.REDIS_PORT || "6379", 10), @@ -193,7 +196,7 @@ async function initializeClients(): Promise { }); console.log(`Auth enabled: provider=${authProvider.id}`); } else { - console.log("Auth not configured — all requests will be treated as anonymous"); + console.warn("SCOPE_AUTH_ENABLED=false: authentication and project RBAC are disabled. Network callers have unrestricted application access; identity administration remains unavailable."); } codebaseCollection = db.collection("codebases"); @@ -313,6 +316,7 @@ const routeCtx: RouteContext = { get resourceResolver() { return resourceResolver; }, get projectStore() { return projectStore; }, get reportQueueClient() { return reportQueueClient; }, + get authEnabled() { return authEnabled; }, get blobStorage() { return blobStorage; }, get heartbeatStore() { return heartbeatStore; }, strictAgentCapabilities, @@ -321,17 +325,27 @@ const routeCtx: RouteContext = { }; // ─── Route registration ─────────────────────────────────────────────────────── +app.locals.authorization = () => authorization; app.use("/api/v1/users/me", (_req, res, next) => { res.setHeader("Cache-Control", "no-store"); next(); }); app.use( createAuthMiddleware({ - getProvider: () => authProvider, + getProvider: () => authEnabled ? authProvider : null, }), ); +app.use(express.json()); registerUsersRoutes(routeCtx); -app.use(createUserAccessMiddleware(() => userAccessResolver)); +app.use(createUserAccessMiddleware(() => authEnabled ? userAccessResolver : null)); +app.use((req, res, next) => { + if (authEnabled && req.path.startsWith("/api/v1/") && !["/api/v1/version"].includes(req.path) && !req.user?.isAuthenticated) { + res.status(401).json({ error: "Authentication required", code: "authentication_required" }); + return; + } + next(); +}); +registerAccessRoutes(routeCtx); // Secrets/proxy routes must be registered first (before :id param routes) registerSecretsRoutes(routeCtx); @@ -365,6 +379,10 @@ registerFeatureFlagRoutes(routeCtx); // Error handler app.use(authErrorHandler); app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof AccessError) { + res.status(err.status).json({ error: err.message, code: err.code }); + return; + } if (err instanceof ProjectScopeError) { res.status(err.status).json({ error: err.message }); return; @@ -419,7 +437,9 @@ async function main(): Promise { export { app }; export interface TestDependencies { + authEnabled?: boolean; db?: Db; + projectStore?: ProjectStore; collection?: Collection; runsCollection?: Collection; criteriaCollection?: Collection; @@ -460,7 +480,10 @@ export interface TestDependencies { /** @internal — used by tests only to inject mock dependencies */ export function _injectTestDependencies(deps: TestDependencies): void { + if (deps.authEnabled !== undefined) authEnabled = deps.authEnabled; if (deps.db) db = deps.db; + if (deps.db || deps.authEnabled !== undefined) authorization = new Authorization(db, authEnabled); + if (deps.projectStore) projectStore = deps.projectStore; if (deps.collection) collection = deps.collection; if (deps.runsCollection) runsCol = deps.runsCollection; if (deps.criteriaCollection) criteriaCollection = deps.criteriaCollection; diff --git a/apps/api/src/openapi-snapshot.test.ts b/apps/api/src/openapi-snapshot.test.ts index 407bb4062..b1a7e42dc 100644 --- a/apps/api/src/openapi-snapshot.test.ts +++ b/apps/api/src/openapi-snapshot.test.ts @@ -44,15 +44,17 @@ describe("OpenAPI spec snapshot", () => { 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[] = []; + const publicPaths = new Set(["/health", "/ready", "/about", "/api/v1/version"]); 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}`); + const operation = item?.[method]; + if (operation) { + expect(operation.security, `${method.toUpperCase()} ${path}`).toEqual( + publicPaths.has(path) ? [] : [{ bearerAuth: [] }], + ); } } } - 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 d4b80d3c0..cdf3390f5 100644 --- a/apps/api/src/openapi/api-route.test.ts +++ b/apps/api/src/openapi/api-route.test.ts @@ -9,6 +9,11 @@ import express from "express"; import request from "supertest"; import { apiRoute, toOpenApiPath } from "./api-route.js"; +vi.mock("../auth/authorization.js", async importOriginal => ({ + ...await importOriginal(), + routePolicy: vi.fn(() => "public"), +})); + extendZodWithOpenApi(z); // ─── toOpenApiPath ─────────────────────────────────────────────────────────── @@ -63,7 +68,7 @@ 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.paths?.["/api/v1/items"]?.get?.security).toEqual([]); expect(doc).not.toHaveProperty("security"); }); diff --git a/apps/api/src/openapi/api-route.ts b/apps/api/src/openapi/api-route.ts index 584e09bad..1ddcfd3a4 100644 --- a/apps/api/src/openapi/api-route.ts +++ b/apps/api/src/openapi/api-route.ts @@ -5,6 +5,8 @@ import { z, type ZodType, type ZodObject } from "zod"; import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; import type { OpenAPIRegistry, RouteConfig } from "@asteasolutions/zod-to-openapi"; import type { Express, Request, Response, NextFunction, RequestHandler } from "express"; +import { routePolicy, protectStream, type Authorization, type RoutePolicy } from "../auth/authorization.js"; +import { AccessError } from "../auth/admin-operations.js"; extendZodWithOpenApi(z); @@ -36,6 +38,7 @@ export interface ApiRouteConfig< /** OpenAPI-only security requirements; enforcement remains in auth middleware/handlers. */ security?: RouteConfig["security"]; + policy?: RoutePolicy; // Schemas (all optional) body?: TBody; @@ -152,6 +155,7 @@ export function apiRoute< const openApiPath = toOpenApiPath(path); const status = successStatus ?? (method === "post" ? 201 : 200); + const policy = config.policy ?? routePolicy(method, path); // ── OpenAPI registration ──────────────────────────────────────────────── @@ -169,7 +173,9 @@ export function apiRoute< } const responses: Record = {}; - if (rawResponse) { + if (policy === "disabled") { + responses["503"] = { description: "New report generation is disabled (report_generation_disabled); no work is enqueued" }; + } else if (rawResponse) { responses[String(status)] = { description: responseDescription ?? "Success", }; @@ -194,7 +200,7 @@ export function apiRoute< tags, summary, ...(description ? { description } : {}), - ...(security !== undefined ? { security } : {}), + security: security ?? (policy === "public" ? [] : [{ bearerAuth: [] }]), ...(Object.keys(request).length > 0 ? { request } : {}), responses, } as Parameters[0]); @@ -240,9 +246,25 @@ export function apiRoute< } }; + const authorizationHandler: RequestHandler = async (req, res, next) => { + try { + if (policy === "disabled") throw new AccessError(503, "report_generation_disabled", "New report generation is disabled"); + const getAuthorization: (() => Authorization | null) | undefined = app.locals.authorization; + const authorization = getAuthorization?.(); + if (policy !== "public") { + if (!authorization) throw new AccessError(503, "authority_unavailable", "Authorization authority unavailable"); + const deadline = await authorization.guard(req, method, path, policy); + if (authorization.enabled !== false && rawResponse && path.endsWith("/logs")) { + protectStream(res, () => authorization.guard(req, method, path, policy), deadline); + } + } + next(); + } catch (error) { next(error); } + }; + if (middleware && middleware.length > 0) { - app[method](path, ...middleware, validationHandler); + app[method](path, authorizationHandler, ...middleware, validationHandler); } else { - app[method](path, validationHandler); + app[method](path, authorizationHandler, validationHandler); } } diff --git a/apps/api/src/openapi/routes/accounts.ts b/apps/api/src/openapi/routes/accounts.ts index 44f336a5d..cea22e317 100644 --- a/apps/api/src/openapi/routes/accounts.ts +++ b/apps/api/src/openapi/routes/accounts.ts @@ -14,6 +14,7 @@ registry.registerPath({ path: "/api/v1/accounts", tags: ["Accounts"], summary: "Create account", + security: [{ bearerAuth: [] }], request: { body: { content: { @@ -37,6 +38,7 @@ registry.registerPath({ path: "/api/v1/accounts", tags: ["Accounts"], summary: "List accounts", + security: [{ bearerAuth: [] }], responses: { 200: { description: "Success", @@ -53,6 +55,7 @@ registry.registerPath({ path: "/api/v1/accounts/{id}", tags: ["Accounts"], summary: "Get account", + security: [{ bearerAuth: [] }], request: { params: z.object({ id: z.string() }), }, @@ -72,6 +75,7 @@ registry.registerPath({ path: "/api/v1/accounts/{id}", tags: ["Accounts"], summary: "Update account", + security: [{ bearerAuth: [] }], request: { params: z.object({ id: z.string() }), body: { @@ -96,6 +100,7 @@ registry.registerPath({ path: "/api/v1/accounts/{id}", tags: ["Accounts"], summary: "Delete account", + security: [{ bearerAuth: [] }], request: { params: z.object({ id: z.string() }), }, diff --git a/apps/api/src/openapi/routes/keys.ts b/apps/api/src/openapi/routes/keys.ts index b1f40610d..f16f5c704 100644 --- a/apps/api/src/openapi/routes/keys.ts +++ b/apps/api/src/openapi/routes/keys.ts @@ -18,6 +18,7 @@ registry.registerPath({ path: "/api/v1/keys/preview", tags: ["Keys"], summary: "Preview key", + security: [{ bearerAuth: [] }], request: { body: { content: { @@ -41,6 +42,7 @@ registry.registerPath({ path: "/api/v1/keys", tags: ["Keys"], summary: "Create key", + security: [{ bearerAuth: [] }], request: { body: { content: { @@ -64,6 +66,7 @@ registry.registerPath({ path: "/api/v1/keys", tags: ["Keys"], summary: "List keys", + security: [{ bearerAuth: [] }], responses: { 200: { description: "Success", @@ -80,6 +83,7 @@ registry.registerPath({ path: "/api/v1/keys/{id}", tags: ["Keys"], summary: "Get key", + security: [{ bearerAuth: [] }], request: { params: z.object({ id: z.string() }), }, @@ -99,6 +103,7 @@ registry.registerPath({ path: "/api/v1/keys/{id}", tags: ["Keys"], summary: "Update key", + security: [{ bearerAuth: [] }], request: { params: z.object({ id: z.string() }), body: { @@ -123,6 +128,7 @@ registry.registerPath({ path: "/api/v1/keys/{id}", tags: ["Keys"], summary: "Delete key", + security: [{ bearerAuth: [] }], request: { params: z.object({ id: z.string() }), }, @@ -144,6 +150,7 @@ registry.registerPath({ path: "/api/v1/keys/{id}/validate", tags: ["Keys"], summary: "Validate key", + security: [{ bearerAuth: [] }], request: { params: z.object({ id: z.string() }), body: { diff --git a/apps/api/src/profile-endpoints.test.ts b/apps/api/src/profile-endpoints.test.ts index ce0592dd5..f32c2858b 100644 --- a/apps/api/src/profile-endpoints.test.ts +++ b/apps/api/src/profile-endpoints.test.ts @@ -5,10 +5,15 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import request from "supertest"; import { app, _injectTestDependencies } from "./index.js"; import { useTestServer } from "./test-server.js"; -import { createAllMockDependencies, createMockCollection } from "./test-helpers.js"; +import { createAllMockDependencies, createMockCollection, mockRouteAuthorization } from "./test-helpers.js"; const TEST_PROJECT_ID = "test-project"; +vi.mock("./auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("./test-helpers.js")).mockControllerUserAccessMiddleware, +})); + // Stub checkMigrations before it can be imported by index.ts vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ @@ -39,6 +44,7 @@ describe("Profile API Endpoints", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/route-context.ts b/apps/api/src/route-context.ts index 580eae160..1b96c9f6e 100644 --- a/apps/api/src/route-context.ts +++ b/apps/api/src/route-context.ts @@ -146,6 +146,7 @@ export interface RouteContext { reportQueueClient: QueueClient; // Config + authEnabled?: boolean; strictAgentCapabilities: boolean; storageConnectionString: string; storageAccountName: string; diff --git a/apps/api/src/routes/access.ts b/apps/api/src/routes/access.ts new file mode 100644 index 000000000..e5c9f7075 --- /dev/null +++ b/apps/api/src/routes/access.ts @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import type { UserDocument } from "shared"; +import { + AccessUserSearchResultSchema, AccessUserSummarySchema, ProjectMembershipSummarySchema, ProjectRoleSchema, + PlatformAdminListSchema, PlatformAdminPromotionSchema, + type AccessUserSummary, type PlatformAdminSummary, type ProjectMembership, type ProjectMembershipSummary, +} from "shared/rbac"; +import type { Filter } from "mongodb"; +import type { RouteContext } from "../route-context.js"; +import { apiRoute } from "../openapi/api-route.js"; +import { Authorization } from "../auth/authorization.js"; +import { AccessError, type AdminOperation } from "../auth/admin-operations.js"; +import type { Request } from "express"; + +export function operationId(req: Request): string { + const id = req.get("Idempotency-Key") ?? randomUUID(); + if (!/^[A-Za-z0-9:_-]{1,160}$/.test(id)) throw new AccessError(400, "invalid_operation_id", "Invalid Idempotency-Key"); + req.res?.setHeader("X-Operation-Id", id); + return id; +} + +const membershipInput = z.object({ + role: ProjectRoleSchema, + revision: z.number().int().nonnegative(), +}).strict(); +const revisionInput = z.object({ revision: z.number().int().nonnegative() }).strict(); +const userSearchQuery = z.object({ + q: z.string().trim().max(100).default(""), + cursor: z.string().min(1).max(200).optional(), + limit: z.coerce.number().int().min(1).max(50).default(20), +}).strict(); +const userProjection = { _id: 1, displayName: 1, email: 1, alias: 1, role: 1, disabledAt: 1, revision: 1 }; + +function userSummary(user: UserDocument): AccessUserSummary { + return { + id: user._id, displayName: user.displayName, email: user.email, alias: user.alias, + role: user.role, disabled: Boolean(user.disabledAt), revision: user.revision ?? 0, + }; +} + +export function registerAccessRoutes(ctx: Pick): void { + const auth = () => new Authorization(ctx.db); + const searchUsers = async ({ q, cursor, limit }: z.infer) => { + const literal = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const filter: Filter = { + ...(cursor ? { _id: { $gt: cursor } } : {}), + ...(literal ? { $or: ["displayName", "email", "alias", "_id"].map(field => ({ [field]: { $regex: literal, $options: "i" } })) } : {}), + }; + const users = await ctx.usersCollection.find(filter, { projection: userProjection, timeoutMS: 5_000 }) + .sort({ _id: 1 }).limit(limit + 1).toArray(); + const page = users.slice(0, limit); + return { + items: page.map(userSummary), + ...(users.length > limit ? { nextCursor: page[page.length - 1]._id } : {}), + }; + }; + apiRoute(ctx.app, ctx.registry, { + method: "get", path: "/api/v1/admin/platform-admins", tags: ["Access"], summary: "List platform administrators, including disabled accounts and pending changes", + query: z.object({ + cursor: z.string().min(1).max(200).optional(), + limit: z.coerce.number().int().min(1).max(50).default(20), + }).strict(), + response: PlatformAdminListSchema, + handler: async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + const { cursor, limit } = req.query; + const users = await ctx.usersCollection.find({ + role: "admin", ...(cursor ? { _id: { $gt: cursor } } : {}), + }, { projection: { ...userProjection, lastOperationId: 1 }, timeoutMS: 5_000 }) + .sort({ _id: 1 }).limit(limit + 1).toArray(); + const page = users.slice(0, limit); + const markers = page.flatMap(user => user.lastOperationId ? [user.lastOperationId] : []); + const operations = markers.length ? await ctx.db.collection("admin_operations") + .find({ _id: { $in: markers }, state: "completed" }, { projection: { _id: 1 }, timeoutMS: 5_000 }).toArray() : []; + const completed = new Set(operations.map(operation => operation._id)); + const items: PlatformAdminSummary[] = page.map(user => ({ + ...userSummary(user), role: "admin", + pending: user.lastOperationId !== undefined && !completed.has(user.lastOperationId), + operationId: user.lastOperationId, + })); + res.json({ items, ...(users.length > limit ? { nextCursor: page[page.length - 1]._id } : {}) }); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "post", path: "/api/v1/admin/users/:id/promote", tags: ["Access"], summary: "Promote an enabled enrolled user to platform administrator", + body: PlatformAdminPromotionSchema, response: z.object({ + operationId: z.string(), state: z.enum(["completed", "pending"]), revision: z.number().int().positive(), + }), + successStatus: 200, + errorResponses: { + 202: { description: "Promotion is pending; retry identical input or ask an operator to inspect/repair the same operation ID before claiming completion" }, + 404: { description: "Enrolled user not found" }, + 409: { description: "Revision, role, enabled state or idempotency precondition conflict" }, + }, + handler: async (req, res) => { + const id = operationId(req); + const user = await ctx.usersCollection.findOne({ _id: req.params.id }, { timeoutMS: 5_000 }); + if (!user) throw new AccessError(404, "not_found", "Enrolled user not found"); + const authority = auth(); + if (user.lastOperationId && user.lastOperationId !== id && !await authority.operations.isComplete(user.lastOperationId)) { + throw new AccessError(409, "operation_pending", `User access change ${user.lastOperationId} requires identical retry or operator repair before promotion`); + } + const result = await authority.operations.execute({ + _id: id, actor: req.user!.id, reason: "Explicit platform-admin promotion of an enrolled user", + collection: "users", targetId: user._id, expectedRevision: req.body.revision, + change: { role: "admin" }, unset: [], + condition: { role: "user", disabledAt: { $exists: false } }, + }); + res.setHeader("Cache-Control", "no-store"); + res.status(result.state === "pending" ? 202 : 200).json(result); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "get", path: "/api/v1/admin/users/search", tags: ["Access"], summary: "Search enrolled platform users (platform administration)", + query: userSearchQuery, + response: AccessUserSearchResultSchema, + handler: async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + res.json(await searchUsers(req.query)); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "get", path: "/api/v1/projects/:id/members/search", tags: ["Access"], summary: "Search enrolled users to add to this project (project or platform administration)", + params: z.object({ id: z.string().min(1) }).strict(), + query: userSearchQuery, + response: AccessUserSearchResultSchema, + handler: async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + res.json(await searchUsers(req.query)); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "get", path: "/api/v1/admin/users", tags: ["Access"], summary: "Look up an exactly identified enrolled user", + query: z.object({ userId: z.string().optional(), idp: z.string().optional(), tenant: z.string().optional(), subject: z.string().optional() }).strict(), + response: z.array(AccessUserSummarySchema), + handler: async (req, res) => { + const { userId, idp, tenant, subject } = req.query; + if ((!userId && !(idp && tenant && subject)) || (userId && (idp || tenant || subject))) { + throw new AccessError(400, "exact_identity_required", "Supply userId or the exact idp, tenant and subject"); + } + const user = await ctx.usersCollection.findOne(userId ? { _id: userId } : { idp, idpTenant: tenant, idpSubject: subject }, { projection: userProjection, timeoutMS: 5_000 }); + res.json(user ? [userSummary(user)] : []); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "get", path: "/api/v1/projects/:id/members/lookup/:userId", tags: ["Access"], summary: "Look up one enrolled Scope UUID for project membership management", + params: z.object({ id: z.string().min(1), userId: z.string().uuid() }).strict(), + response: AccessUserSummarySchema, + handler: async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + const user = await ctx.usersCollection.findOne({ _id: req.params.userId }, { projection: userProjection, timeoutMS: 5_000 }); + if (!user) throw new AccessError(404, "not_found", "Enrolled user not found. Ask the person to sign in and share their Scope user ID."); + if (user.disabledAt) throw new AccessError(409, "user_disabled", "Disabled users cannot receive new project access"); + res.json(userSummary(user)); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "get", path: "/api/v1/projects/:id/members", tags: ["Access"], summary: "List project memberships (project or platform administration)", + response: z.array(ProjectMembershipSummarySchema), + handler: async (req, res) => { + res.setHeader("Cache-Control", "no-store"); + const members = await ctx.db.collection("project_memberships") + .find({ projectId: req.params.id }, { timeoutMS: 5_000 }).toArray(); + const ids = members.map(member => member.userId); + const markers = members.map(member => member.lastOperationId); + const [users, operations] = await Promise.all([ + ids.length ? ctx.usersCollection.find({ _id: { $in: ids } }, { projection: userProjection, timeoutMS: 5_000 }).toArray() : [], + markers.length ? ctx.db.collection("admin_operations") + .find({ _id: { $in: markers }, state: "completed" }, { projection: { _id: 1 }, timeoutMS: 5_000 }).toArray() : [], + ]); + const enrolled = new Map(users.map(user => [user._id, userSummary(user)])); + const completed = new Set(operations.map(operation => operation._id)); + const summaries: ProjectMembershipSummary[] = members.map(member => ({ + _id: member._id, projectId: member.projectId, userId: member.userId, role: member.role, + revision: member.revision, revokedAt: member.revokedAt?.toISOString(), + pending: !completed.has(member.lastOperationId), operationId: member.lastOperationId, user: enrolled.get(member.userId), + })); + res.json(summaries); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "put", path: "/api/v1/projects/:id/members/:userId", tags: ["Access"], summary: "Explicitly grant or change a project membership", + body: membershipInput, response: z.unknown(), + handler: async (req, res) => { + const project = await ctx.projectStore.get(req.params.id); + const user = await ctx.usersCollection.findOne({ _id: req.params.userId }, { timeoutMS: 5_000 }); + if (!project || !user) throw new AccessError(404, "not_found", "Project or enrolled user not found"); + if (user.disabledAt) throw new AccessError(409, "user_disabled", "Disabled users cannot receive new project access"); + const id = operationId(req); + const actor = await auth().currentUser(req); + const result = await auth().operations.execute({ + _id: id, actor: actor._id, reason: actor.role === "admin" + ? "Explicit platform-admin membership assignment" : "Explicit project-admin membership assignment", + collection: "project_memberships", targetId: `${project._id}:${user._id}`, + expectedRevision: req.body.revision, change: { role: req.body.role }, unset: ["revokedAt"], + initial: { projectId: project._id, userId: user._id, createdByUserId: req.user!.id }, + }); + res.status(result.state === "pending" ? 202 : 200).json(result); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "delete", path: "/api/v1/projects/:id/members/:userId", tags: ["Access"], summary: "Revoke a project membership", + body: revisionInput, response: z.unknown(), + handler: async (req, res) => { + const actor = await auth().currentUser(req); + const result = await auth().operations.execute({ + _id: operationId(req), actor: actor._id, reason: actor.role === "admin" + ? "Explicit platform-admin membership revocation" : "Explicit project-admin membership revocation", + collection: "project_memberships", targetId: `${req.params.id}:${req.params.userId}`, + expectedRevision: req.body.revision, change: { revokedAt: "$operationTime" }, unset: [], + }); + res.status(result.state === "pending" ? 202 : 200).json(result); + }, + }); + apiRoute(ctx.app, ctx.registry, { + method: "post", path: "/api/v1/admin/users/:id/disable", tags: ["Access"], summary: "Disable an ordinary user", + body: revisionInput, response: z.unknown(), + handler: async (req, res) => { + const user = await ctx.usersCollection.findOne({ _id: req.params.id }, { timeoutMS: 5_000 }); + if (!user) throw new AccessError(404, "not_found", "User not found"); + if (user.role !== "user") throw new AccessError(403, "operator_required", "Disabling platform administrators is operator-managed"); + const result = await auth().operations.execute({ + _id: operationId(req), actor: req.user!.id, reason: "Platform-admin ordinary-user disable", + collection: "users", targetId: user._id, expectedRevision: req.body.revision, + change: { disabledAt: "$operationTime" }, unset: [], condition: { role: "user" }, + }); + res.status(result.state === "pending" ? 202 : 200).json(result); + }, + }); +} diff --git a/apps/api/src/routes/agents.test.ts b/apps/api/src/routes/agents.test.ts index c879e5d22..07f877965 100644 --- a/apps/api/src/routes/agents.test.ts +++ b/apps/api/src/routes/agents.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import type { RouteContext } from "../route-context.js"; import { registerAgentsRoutes } from "./agents.js"; +import { mockRouteAuthorization } from "../test-helpers.js"; extendZodWithOpenApi(z); @@ -59,6 +60,7 @@ function buildApp() { } as unknown as RouteContext["agentCollection"]; const app = express(); + mockRouteAuthorization(app); app.use(express.json()); registerAgentsRoutes({ app, @@ -96,6 +98,7 @@ function buildVersionApp(documents: Array>) { } as unknown as RouteContext["agentCollection"]; const app = express(); + mockRouteAuthorization(app); app.use(express.json()); registerAgentsRoutes({ app, diff --git a/apps/api/src/routes/catalog-isolation.test.ts b/apps/api/src/routes/catalog-isolation.test.ts index d9ba370f5..b54e19471 100644 --- a/apps/api/src/routes/catalog-isolation.test.ts +++ b/apps/api/src/routes/catalog-isolation.test.ts @@ -64,6 +64,7 @@ function buildCtx( over: Partial, ): { app: ReturnType; ctx: RouteContext } { const app = express(); + app.locals.authorization = () => ({ guard: vi.fn(async () => performance.now() + 20_000) }); app.use(express.json()); const registry = new OpenAPIRegistry(); diff --git a/apps/api/src/routes/criteria.ts b/apps/api/src/routes/criteria.ts index 36564769e..8296a5f24 100644 --- a/apps/api/src/routes/criteria.ts +++ b/apps/api/src/routes/criteria.ts @@ -19,7 +19,7 @@ import { computeMdp } from "../criteria-mdp.js"; import type { MdpAnalyzableRun } from "../criteria-mdp.js"; import { generateCriteriaPrompt, isLlmAvailable } from "../llm.js"; import { isInferenceError } from "../llm-token.js"; -import { ProjectIdQuerySchema, OptionalProjectIdQuerySchema, getQueryProjectId, getOptionalQueryProjectId } from "../utils/project-scope.js"; +import { ProjectIdQuerySchema, getQueryProjectId } from "../utils/project-scope.js"; /** * Map a thrown {@link CriteriaStoreError} to an HTTP response. Returns true when @@ -60,7 +60,7 @@ apiRoute(ctx.app, ctx.registry, { path: "/api/v1/criteria/generate-prompt", tags: ["Criteria"], summary: "Generate criterion prompt from behavior", - query: OptionalProjectIdQuerySchema, + query: ProjectIdQuerySchema, body: z.object({ behavior: z.string(), currentId: z.string().optional(), @@ -88,9 +88,8 @@ apiRoute(ctx.app, ctx.registry, { return; } - const generateProjectId = getOptionalQueryProjectId(req); const allCriteria = await ctx.criteriaCollection - .find({ ...(generateProjectId ? { projectId: generateProjectId } : {}), deletedAt: { $exists: false } }) + .find({ projectId: getQueryProjectId(req), deletedAt: { $exists: false } }) .project({ id: 1, prompt: 1, dependsOn: 1, gates: 1, _id: 0 }) .toArray(); @@ -306,8 +305,9 @@ apiRoute(ctx.app, ctx.registry, { : undefined; const sinceDate = req.query.since ? new Date(req.query.since) : undefined; + const mdpProjectId = getQueryProjectId(req); const mdpFilter: Record = { - projectId: getQueryProjectId(req), + projectId: mdpProjectId, "run.status": "done", deletedAt: { $exists: false }, }; @@ -339,7 +339,7 @@ apiRoute(ctx.app, ctx.registry, { >(); if (taskPromptIds.length > 0) { const taskPrompts = await ctx.taskPromptCollection - .find({ _id: { $in: taskPromptIds } }) + .find({ _id: { $in: taskPromptIds }, projectId: mdpProjectId }) .project({ _id: 1, features: 1 }) .toArray(); for (const tp of taskPrompts) { diff --git a/apps/api/src/routes/insights.ts b/apps/api/src/routes/insights.ts index 98f7ed56b..c12721eac 100644 --- a/apps/api/src/routes/insights.ts +++ b/apps/api/src/routes/insights.ts @@ -406,7 +406,7 @@ apiRoute(ctx.app, ctx.registry, { } const reports = await ctx.reportCollection - .find({ "insightReferences.insightId": id }) + .find({ "insightReferences.insightId": id, projectId: insight.projectId }) .sort({ createdAt: -1 }) .toArray(); diff --git a/apps/api/src/routes/mcp-servers.test.ts b/apps/api/src/routes/mcp-servers.test.ts index 6c0ab36a9..ba6fd99da 100644 --- a/apps/api/src/routes/mcp-servers.test.ts +++ b/apps/api/src/routes/mcp-servers.test.ts @@ -26,6 +26,7 @@ function buildCtx(overrides: { insertOneSpy?: ReturnType; }): { app: ReturnType; ctx: RouteContext } { const app = express(); + app.locals.authorization = () => ({ guard: vi.fn(async () => performance.now() + 20_000) }); app.use(express.json()); const registry = new OpenAPIRegistry(); diff --git a/apps/api/src/routes/project-membership.integration.test.ts b/apps/api/src/routes/project-membership.integration.test.ts new file mode 100644 index 000000000..a4e708fba --- /dev/null +++ b/apps/api/src/routes/project-membership.integration.test.ts @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { MongoClient, type Db } from "mongodb"; +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectStore, type ProjectDocument, type UserDocument } from "shared"; +import { Authorization } from "../auth/authorization.js"; +import { AccessError, AdminOperations, type AdminOperation } from "../auth/admin-operations.js"; +import { registerAccessRoutes } from "./access.js"; + +const uri = process.env.AUTH_TEST_MONGO_URI; +describe.runIf(Boolean(uri))("Project-admin membership routes with canonical authority and durable writes", () => { + const database = `project-member-test-${randomUUID()}`; + let projectId: string, otherProject: string, adminId: string, platformId: string, targetId: string; + const app = express(); + let client: MongoClient; + let db: Db; + let operations: AdminOperations; + let authority: Authorization; + + beforeAll(async () => { + if (!uri) throw new Error("AUTH_TEST_MONGO_URI is required"); + client = await new MongoClient(uri).connect(); + db = client.db(database); + operations = new AdminOperations(db); + authority = new Authorization(db); + await db.collection("project_memberships").createIndex({ projectId: 1, userId: 1 }, { unique: true }); + app.use(express.json()); + app.use((req, _res, next) => { + const actor = req.get("X-Test-Verified-Actor") ?? adminId; + // This harness starts AFTER bearer verification; canonical authority is real. + req.user = { id: actor, role: "user", isAuthenticated: true }; + req.auth = { + token: `header.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 300 })).toString("base64url")}.signature`, + }; + next(); + }); + app.locals.authorization = () => authority; + registerAccessRoutes({ + app, registry: new OpenAPIRegistry(), db, usersCollection: db.collection("users"), + projectStore: new ProjectStore(db.collection("projects")), + }); + app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error instanceof AccessError ? error.status : 500).json({ + error: error.message, code: error instanceof AccessError ? error.code : "unexpected", + }); + }); + }); + beforeEach(async () => { + projectId = randomUUID(); otherProject = randomUUID(); + adminId = randomUUID(); platformId = randomUUID(); targetId = randomUUID(); + const now = new Date(); + await db.collection("users").insertMany([ + { _id: adminId, idp: "entra", idpTenant: "fixture", idpSubject: adminId, role: "user", createdAt: now, updatedAt: now }, + { _id: platformId, idp: "entra", idpTenant: "fixture", idpSubject: platformId, role: "admin", createdAt: now, updatedAt: now }, + { _id: targetId, idp: "entra", idpTenant: "fixture", idpSubject: targetId, role: "user", displayName: "Enrolled target", createdAt: now, updatedAt: now }, + ]); + await db.collection("projects").insertMany([{ _id: projectId, name: "Project A" }, { _id: otherProject, name: "Project B" }]); + await grant(adminId, "admin", 0); + }); + afterAll(async () => { if (db) await db.dropDatabase(); await client?.close(); }); + afterEach(() => vi.restoreAllMocks()); + + function grant(userId: string, role: "reader" | "contributor" | "admin", revision: number) { + return operations.execute({ + _id: randomUUID(), actor: platformId, reason: "Isolated membership fixture", + collection: "project_memberships", targetId: `${projectId}:${userId}`, expectedRevision: revision, + change: { role }, unset: ["revokedAt"], initial: { projectId, userId, createdByUserId: platformId }, + }); + } + const memberPath = (userId = targetId, id = projectId) => `/api/v1/projects/${id}/members/${userId}`; + + it("adds a new enrolled user, edits every fixed role and revokes without changing identity or platform role", async () => { + const before = await db.collection("users").findOne({ _id: targetId }); + const lookup = await request(app).get(`/api/v1/projects/${projectId}/members/lookup/${targetId}`); + expect(lookup.status).toBe(200); + expect(lookup.headers["cache-control"]).toBe("no-store"); + expect(lookup.body).toEqual({ id: targetId, displayName: "Enrolled target", role: "user", disabled: false, revision: 0 }); + expect(lookup.body).not.toHaveProperty("idpSubject"); + let revision = 0; + for (const role of ["reader", "contributor", "admin"]) { + const id = randomUUID(); + const result = await request(app).put(memberPath()).set("Idempotency-Key", id).send({ role, revision }); + expect(result.status).toBe(200); + expect(result.body).toMatchObject({ operationId: id, state: "completed", revision: ++revision }); + const replay = await request(app).put(memberPath()).set("Idempotency-Key", id).send({ role, revision: revision - 1 }); + expect(replay.body).toEqual(result.body); + const evidence = await db.collection("admin_operations").findOne({ _id: id }); + expect(evidence).toMatchObject({ + actor: adminId, reason: "Explicit project-admin membership assignment", collection: "project_memberships", + targetId: `${projectId}:${targetId}`, state: "completed", expectedRevision: revision - 1, change: { role }, + }); + } + const roster = await request(app).get(`/api/v1/projects/${projectId}/members`); + expect(roster.status).toBe(200); + expect(roster.body).toEqual(expect.arrayContaining([expect.objectContaining({ userId: targetId, role: "admin", revision: 3, pending: false })])); + const revokeId = randomUUID(); + expect((await request(app).delete(memberPath()).set("Idempotency-Key", revokeId).send({ revision })).status).toBe(200); + expect(await db.collection("admin_operations").findOne({ _id: revokeId })).toMatchObject({ + actor: adminId, reason: "Explicit project-admin membership revocation", state: "completed", + }); + expect(await authority.memberships(targetId)).toEqual([]); + expect(await db.collection("users").findOne({ _id: targetId })).toEqual(before); + expect(await db.collection("project_memberships").countDocuments({ projectId: otherProject })).toBe(0); + }); + + it("returns 404 for foreign projects and 403 for readers/contributors before roster or target discovery", async () => { + for (const [path, method] of [ + [`/api/v1/projects/${otherProject}/members`, "get"], + [`/api/v1/projects/${otherProject}/members/search?q=Enrolled`, "get"], + [`/api/v1/projects/${otherProject}/members/lookup/${targetId}`, "get"], + [memberPath(targetId, otherProject), "put"], [memberPath(targetId, otherProject), "delete"], + ] as const) { + expect((await request(app)[method](path).send({ role: "admin", revision: 0 })).status).toBe(404); + } + let revision = 0; + for (const role of ["reader", "contributor"] as const) { + expect((await grant(targetId, role, revision++)).state).toBe("completed"); + for (const [path, method] of [ + [`/api/v1/projects/${projectId}/members`, "get"], + [`/api/v1/projects/${projectId}/members/search?q=Enrolled`, "get"], + [`/api/v1/projects/${projectId}/members/lookup/${randomUUID()}`, "get"], + [memberPath(), "put"], [memberPath(), "delete"], + ] as const) { + expect((await request(app)[method](path).set("X-Test-Verified-Actor", targetId).send({ role: "admin", revision: revision })).status).toBe(403); + } + } + for (const path of ["/api/v1/admin/users/search", "/api/v1/admin/users", "/api/v1/admin/platform-admins"]) { + expect((await request(app).get(path)).status).toBe(403); + } + }); + + it("searches minimal enrolled-user summaries by name, email, alias and Scope UUID with bounded literal pagination", async () => { + const prefix = `Member search ${randomUUID()}`; + const now = new Date(); + const candidates: UserDocument[] = ["Alice", "Bob.*[", "Disabled"].map((name, index) => ({ + _id: randomUUID(), idp: "entra", idpTenant: "fixture", idpSubject: randomUUID(), role: "user", + displayName: `${prefix} ${name}`, email: `${index}-${prefix}@example.test`, alias: `${prefix}-${index}`, + ...(index === 2 ? { disabledAt: now } : {}), createdAt: now, updatedAt: now, + })); + await db.collection("users").insertMany(candidates); + const path = `/api/v1/projects/${projectId}/members/search`; + const first = await request(app).get(path).query({ q: prefix.toUpperCase(), limit: 2 }); + expect(first.status).toBe(200); + expect(first.headers["cache-control"]).toBe("no-store"); + expect(first.body.items).toHaveLength(2); + expect(first.body.nextCursor).toBeTruthy(); + const second = await request(app).get(path).query({ q: prefix, limit: 2, cursor: first.body.nextCursor }); + expect(second.status).toBe(200); + expect(second.body.items).toHaveLength(1); + expect(second.body.nextCursor).toBeUndefined(); + const items: { id: string; disabled: boolean }[] = [...first.body.items, ...second.body.items]; + expect(new Set(items.map(user => user.id))).toEqual(new Set(candidates.map(user => user._id))); + expect(items.find(user => user.id === candidates[2]._id)?.disabled).toBe(true); + for (const item of items) { + expect(Object.keys(item).sort()).toEqual(["alias", "disabled", "displayName", "email", "id", "revision", "role"]); + } + for (const q of [candidates[0].email, candidates[0].alias, candidates[0]._id]) { + const result = await request(app).get(path).query({ q }); + expect(result.status).toBe(200); + expect(result.body.items.map((user: { id: string }) => user.id)).toEqual([candidates[0]._id]); + } + const literal = await request(app).get(path).query({ q: `${prefix} Bob.*[` }); + expect(literal.status).toBe(200); + expect(literal.body.items.map((user: { id: string }) => user.id)).toEqual([candidates[1]._id]); + const missing = await request(app).get(path).query({ q: randomUUID() }); + expect(missing.status).toBe(200); + expect(missing.body).toEqual({ items: [] }); + for (const query of [{ limit: 0 }, { limit: 51 }, { q: "x".repeat(101) }, { cursor: "" }, { role: "admin" }]) { + expect((await request(app).get(path).query(query)).status).toBe(400); + } + expect((await request(app).get(path).set("X-Test-Verified-Actor", platformId)).status).toBe(200); + expect((await request(app).get(`/api/v1/projects/${otherProject}/members/search`).query({ q: "x".repeat(101) })).status).toBe(404); + expect((await request(app).put(memberPath(adminId)).send({ role: "reader", revision: 1 })).status).toBe(200); + expect((await request(app).get(path)).status).toBe(403); + }); + + it("rejects tampered fields, unenrolled/disabled targets and stale revisions without a grant", async () => { + await grant(targetId, "reader", 0); + for (const extra of [{ projectId: otherProject }, { userId: platformId }, { platformRole: "admin" }, { permissions: ["*"] }, { role: "owner" }]) { + expect((await request(app).put(memberPath()).send({ role: "admin", revision: 1, ...extra })).status).toBe(400); + } + expect((await request(app).put(memberPath()).send({ role: "admin", revision: 0 })).status).toBe(409); + const missing = randomUUID(); + expect((await request(app).get(`/api/v1/projects/${projectId}/members/lookup/${missing}`)).status).toBe(404); + expect((await request(app).put(memberPath(missing)).send({ role: "admin", revision: 0 })).status).toBe(404); + expect(await db.collection("users").findOne({ _id: missing })).toBeNull(); + await db.collection("users").updateOne({ _id: targetId }, { $set: { disabledAt: new Date() } }); + expect((await request(app).get(`/api/v1/projects/${projectId}/members/lookup/${targetId}`)).status).toBe(409); + expect((await request(app).put(memberPath()).send({ role: "admin", revision: 1 })).status).toBe(409); + await db.collection("users").updateOne({ _id: targetId }, { $unset: { disabledAt: "" } }); + }); + + it("allows self-demotion and removal without a last-admin rule and immediately removes management authority", async () => { + expect((await request(app).put(memberPath(adminId)).send({ role: "reader", revision: 1 })).status).toBe(200); + expect((await request(app).get(`/api/v1/projects/${projectId}/members`)).status).toBe(403); + expect((await grant(adminId, "admin", 2)).state).toBe("completed"); + expect((await request(app).delete(memberPath(adminId)).send({ revision: 3 })).status).toBe(200); + expect((await request(app).get(`/api/v1/projects/${projectId}/members`)).status).toBe(404); + const roster = await request(app).get(`/api/v1/projects/${projectId}/members`).set("X-Test-Verified-Actor", platformId); + expect(roster.status).toBe(200); + expect((await request(app).put(memberPath(adminId)).set("X-Test-Verified-Actor", platformId).send({ role: "admin", revision: 4 })).status).toBe(200); + expect((await request(app).get(`/api/v1/projects/${projectId}/members`)).status).toBe(200); + }); + + it("retains the project actor and exact intent through pending completion and same-key retry without HTTP recovery", async () => { + const id = randomUUID(), enrolled = randomUUID(), now = new Date(); + await db.collection("users").insertOne({ + _id: enrolled, idp: "entra", idpTenant: "fixture", idpSubject: enrolled, role: "user", createdAt: now, updatedAt: now, + }); + const collection = db.collection.bind(db); + const evidence = collection("admin_operations"); + vi.spyOn(db, "collection").mockImplementation(name => name === "admin_operations" ? evidence : collection(name)); + const update = vi.spyOn(evidence, "updateOne").mockRejectedValueOnce(new Error("Injected completion failure")); + const pending = await request(app).put(memberPath(enrolled)).set("Idempotency-Key", id).send({ role: "admin", revision: 0 }); + expect(pending.status).toBe(202); + expect(pending.body).toMatchObject({ state: "pending", operationId: id }); + expect(await authority.memberships(enrolled)).toEqual([]); + expect(await evidence.findOne({ _id: id })).toMatchObject({ actor: adminId, state: "prepared", change: { role: "admin" } }); + update.mockRestore(); + expect((await request(app).put(memberPath(enrolled)).set("Idempotency-Key", id).send({ role: "reader", revision: 0 })).status).toBe(409); + const retry = await request(app).put(memberPath(enrolled)).set("Idempotency-Key", id).send({ role: "admin", revision: 0 }); + expect(retry.status).toBe(200); + expect(retry.body).toMatchObject({ operationId: id, revision: 1, state: "completed" }); + expect((await authority.memberships(enrolled)).map(m => m.role)).toEqual(["admin"]); + expect((await request(app).post(`/api/v1/admin/operations/${id}/reconcile`).send({})).status).toBe(404); + expect((await request(app).post(`/api/v1/admin/operations/${id}/reconcile`).set("X-Test-Verified-Actor", platformId).send({})).status).toBe(404); + expect((await request(app).get(`/api/v1/admin/operations/${id}`).set("X-Test-Verified-Actor", platformId)).status).toBe(404); + expect(await evidence.findOne({ _id: id })).toMatchObject({ actor: adminId, state: "completed" }); + }); +}); diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index ac5172c37..7e3bc47cd 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -9,6 +9,9 @@ import { } from "shared"; import { apiRoute } from "../openapi/api-route.js"; import type { RouteContext } from "../route-context.js"; +import { Authorization } from "../auth/authorization.js"; +import { operationId } from "./access.js"; +import { createHash } from "node:crypto"; /** * Projects CRUD. @@ -20,6 +23,7 @@ import type { RouteContext } from "../route-context.js"; * it and its data from the default listings; restoring brings everything back. */ export function registerProjectsRoutes(ctx: RouteContext): void { + const authority = () => new Authorization(ctx.db, ctx.authEnabled); // List all projects (newest first). Pass `?includeDeleted=true` to also return // soft-deleted projects (so the Portal/CLI can offer a restore affordance). apiRoute(ctx.app, ctx.registry, { @@ -32,13 +36,16 @@ export function registerProjectsRoutes(ctx: RouteContext): void { .enum(["true", "false"]) .optional() .describe("When true, include soft-deleted projects in the result"), + controlPlane: z.enum(["true", "false"]).optional(), }), response: z.array(ProjectResponseSchema), handler: async (req, res, next) => { try { const includeDeleted = req.query.includeDeleted === "true"; const projects = await ctx.projectStore.list({ includeDeleted }); - res.json(projects.map((p) => ({ ...p, id: p._id }))); + const memberships = ctx.authEnabled === false ? [] : await authority().memberships(req.user!.id); + const visible = ctx.authEnabled === false || req.query.controlPlane === "true" ? projects : projects.filter(p => memberships.some(m => m.projectId === p._id)); + res.json(visible.map((p) => ({ ...p, id: p._id }))); } catch (error) { next(error); } @@ -51,14 +58,25 @@ export function registerProjectsRoutes(ctx: RouteContext): void { path: "/api/v1/projects", tags: ["Projects"], summary: "Create a project", - body: CreateProjectInputSchema, + body: CreateProjectInputSchema.strict(), response: ProjectResponseSchema, successStatus: 201, errorResponses: { 400: { description: "Invalid input" } }, handler: async (req, res, next) => { try { - const project = await ctx.projectStore.create(req.body); - res.status(201).json({ ...project, id: project._id }); + const id = operationId(req); + const hash = createHash("sha256").update(`project:${id}`).digest("hex"); + const projectId = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-8${hash.slice(17, 20)}-${hash.slice(20, 32)}`; + const result = await authority().operations.execute({ + _id: id, actor: req.user!.id, reason: ctx.authEnabled === false ? "Anonymous project creation (authentication disabled)" : "Platform-admin project creation", + collection: "projects", targetId: projectId, expectedRevision: 0, + change: { name: req.body.name, ...(req.body.description !== undefined ? { description: req.body.description } : {}) }, + unset: [], initial: {}, + ...(ctx.authEnabled === false ? {} : { creatorMembershipOperationId: `project-creator:${hash}` }), + }); + if (result.state === "pending") { res.status(202).json(result); return; } + const project = await ctx.projectStore.get(projectId); + res.status(201).json({ ...project, id: projectId, operationId: result.operationId }); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (/name is required/i.test(message)) { @@ -98,12 +116,19 @@ export function registerProjectsRoutes(ctx: RouteContext): void { path: "/api/v1/projects/:id", tags: ["Projects"], summary: "Update a project", - body: UpdateProjectInputSchema, + body: UpdateProjectInputSchema.extend({ revision: z.number().int().nonnegative() }).strict(), response: ProjectResponseSchema, errorResponses: { 404: { description: "Project not found" } }, handler: async (req, res, next) => { try { - const updated = await ctx.projectStore.update(req.params.id, req.body); + const result = await authority().operations.execute({ + _id: operationId(req), actor: req.user!.id, reason: ctx.authEnabled === false ? "Anonymous project settings update (authentication disabled)" : "Project settings update", + collection: "projects", targetId: req.params.id, expectedRevision: req.body.revision, + change: { ...(req.body.name !== undefined ? { name: req.body.name } : {}), ...(req.body.description !== undefined ? { description: req.body.description } : {}) }, + unset: [], condition: { deletedAt: { $exists: false } }, + }); + if (result.state === "pending") { res.status(202).json(result); return; } + const updated = await ctx.projectStore.get(req.params.id); if (!updated) { res.status(404).json({ error: "Project not found" }); return; @@ -122,6 +147,7 @@ export function registerProjectsRoutes(ctx: RouteContext): void { path: "/api/v1/projects/:id", tags: ["Projects"], summary: "Soft-delete a project", + body: z.object({ revision: z.number().int().nonnegative() }).strict(), response: z.any(), rawResponse: true, successStatus: 204, @@ -130,11 +156,12 @@ export function registerProjectsRoutes(ctx: RouteContext): void { }, handler: async (req, res, next) => { try { - const ok = await ctx.projectStore.softDelete(req.params.id); - if (!ok) { - res.status(404).json({ error: "Project not found" }); - return; - } + const result = await authority().operations.execute({ + _id: operationId(req), actor: req.user!.id, reason: ctx.authEnabled === false ? "Anonymous project deletion (authentication disabled)" : "Platform-admin project deletion", + collection: "projects", targetId: req.params.id, expectedRevision: req.body.revision, + change: { deletedAt: "$operationTime" }, unset: [], + }); + if (result.state === "pending") { res.status(202).json(result); return; } res.status(204).send(); } catch (error) { next(error); @@ -149,6 +176,7 @@ export function registerProjectsRoutes(ctx: RouteContext): void { path: "/api/v1/projects/:id/restore", tags: ["Projects"], summary: "Restore a soft-deleted project", + body: z.object({ revision: z.number().int().nonnegative() }).strict(), response: ProjectResponseSchema, errorResponses: { 404: { description: "Project not found" } }, handler: async (req, res, next) => { @@ -158,12 +186,13 @@ export function registerProjectsRoutes(ctx: RouteContext): void { res.status(404).json({ error: "Project not found" }); return; } - // Already active: nothing to restore, return the current document. - if (!existing.deletedAt) { - res.json({ ...existing, id: existing._id }); - return; - } - const restored = await ctx.projectStore.restore(req.params.id); + const result = await authority().operations.execute({ + _id: operationId(req), actor: req.user!.id, reason: ctx.authEnabled === false ? "Anonymous project restoration (authentication disabled)" : "Platform-admin project restoration", + collection: "projects", targetId: req.params.id, expectedRevision: req.body.revision, + change: {}, unset: ["deletedAt"], + }); + if (result.state === "pending") { res.status(202).json(result); return; } + const restored = await ctx.projectStore.get(req.params.id); if (!restored) { res.status(404).json({ error: "Project not found" }); return; diff --git a/apps/api/src/routes/prompt-features.ts b/apps/api/src/routes/prompt-features.ts index 30e96f5ef..6996aad93 100644 --- a/apps/api/src/routes/prompt-features.ts +++ b/apps/api/src/routes/prompt-features.ts @@ -13,7 +13,7 @@ import { apiRoute } from "../openapi/api-route.js"; import type { PromptFeatureDocument, RouteContext } from "../route-context.js"; import { extractPromptFeatures, generatePromptFeaturePrompt, isLlmAvailable as isPromptFeatureLlmAvailable } from "../prompt-feature-llm.js"; import { isInferenceError } from "../llm-token.js"; -import { ProjectIdQuerySchema, OptionalProjectIdQuerySchema, getQueryProjectId, getOptionalQueryProjectId } from "../utils/project-scope.js"; +import { ProjectIdQuerySchema, getQueryProjectId } from "../utils/project-scope.js"; export function registerPromptFeaturesRoutes(ctx: RouteContext): void { @@ -25,7 +25,7 @@ apiRoute(ctx.app, ctx.registry, { path: "/api/v1/prompt-features/generate-prompt", tags: ["Prompt Features"], summary: "Generate prompt feature from behavior", - query: OptionalProjectIdQuerySchema, + query: ProjectIdQuerySchema, body: z.object({ behavior: z.string(), currentId: z.string().optional(), @@ -47,9 +47,8 @@ apiRoute(ctx.app, ctx.registry, { return; } - const genProjectId = getOptionalQueryProjectId(req); const allFeatures = await ctx.promptFeatureCollection - .find({ ...(genProjectId ? { projectId: genProjectId } : {}), deletedAt: { $exists: false } }) + .find({ projectId: getQueryProjectId(req), deletedAt: { $exists: false } }) .project({ id: 1, prompt: 1, _id: 0 }) .toArray(); @@ -138,7 +137,7 @@ apiRoute(ctx.app, ctx.registry, { path: "/api/v1/prompt-features/extract-from-text", tags: ["Prompt Features"], summary: "Extract features from text", - query: OptionalProjectIdQuerySchema, + query: ProjectIdQuerySchema, body: z.object({ text: z.string(), model: z.string().optional(), @@ -167,14 +166,14 @@ apiRoute(ctx.app, ctx.registry, { // Scope candidate features to the requested type (default = select, // including legacy features with no `type`). - const extractProjectId = getOptionalQueryProjectId(req); + const extractProjectId = getQueryProjectId(req); const promptType = type ?? "select"; const typeFilter: Record = promptType === "select" ? { $or: [{ type: "select" }, { type: { $exists: false } }] } : { type: promptType }; const allFeatures = await ctx.promptFeatureCollection - .find({ ...(extractProjectId ? { projectId: extractProjectId } : {}), deletedAt: { $exists: false }, ...typeFilter }) + .find({ projectId: extractProjectId, deletedAt: { $exists: false }, ...typeFilter }) .toArray(); const featureConfigs = allFeatures.map(f => ({ id: f.id, prompt: f.prompt })); diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts index d6dd7aa32..1342e5772 100644 --- a/apps/api/src/routes/reports.ts +++ b/apps/api/src/routes/reports.ts @@ -659,7 +659,7 @@ apiRoute(ctx.app, ctx.registry, { const insightIds = report.insightReferences.map((ref: InsightReference) => ref.insightId); const insights = await ctx.insightsCollection - .find({ _id: { $in: insightIds }, deletedAt: { $exists: false } }) + .find({ _id: { $in: insightIds }, projectId: report.projectId, deletedAt: { $exists: false } }) .toArray(); // Enrich with reference metadata diff --git a/apps/api/src/routes/requests/archive.test.ts b/apps/api/src/routes/requests/archive.test.ts index e6bc8a176..6728f47b1 100644 --- a/apps/api/src/routes/requests/archive.test.ts +++ b/apps/api/src/routes/requests/archive.test.ts @@ -25,6 +25,8 @@ import { gzipSync, gunzipSync } from "zlib"; import { pack as tarPack, extract as tarExtract } from "tar-stream"; import { RestError } from "@azure/storage-blob"; import { computeTaskPromptId } from "shared"; +import { stringify as yamlStringify } from "yaml"; +import { mockRouteAuthorization } from "../../test-helpers.js"; const TEST_PROJECT_ID = "test-project"; @@ -244,6 +246,7 @@ function buildApp( taskPromptStore: ReturnType = makeTaskPromptStore(), ): Express { const app = express(); + mockRouteAuthorization(app); app.use(express.json({ limit: "10mb" })); const ctx: any = { @@ -379,7 +382,7 @@ describe("run import/export — validation (POST /api/v1/runs/upload)", () => { it("returns 409 when a run with the same _id already exists", async () => { const reqs = makeRequestCollection(); - reqs.docs.set("dup-1", { _id: "dup-1", run: { status: "done" } }); + reqs.docs.set("dup-1", { _id: "dup-1", projectId: TEST_PROJECT_ID, run: { status: "done" } }); const app = buildApp(reqs); const archive = await buildTarGz({ @@ -454,7 +457,7 @@ describe("run import/export — validation (POST /api/v1/runs/upload)", () => { // the ifNoneMatch guard on uploadStream must refuse to overwrite, and // the per-run cleanup must not delete the existing blob either. const reqs = makeRequestCollection(); - reqs.docs.set("dup-noclob", { _id: "dup-noclob", run: { status: "done" } }); + reqs.docs.set("dup-noclob", { _id: "dup-noclob", projectId: TEST_PROJECT_ID, run: { status: "done" } }); const app = buildApp(reqs); const originalIterBytes = Buffer.from("ORIGINAL iteration contents"); @@ -472,12 +475,53 @@ describe("run import/export — validation (POST /api/v1/runs/upload)", () => { .post(`/api/v1/runs/upload?projectId=${TEST_PROJECT_ID}`) .attach("archive", archive, "archive.tar.gz"); - // Status may be 409 (Mongo dup wins the race) or 500 (uploadStream's - // 412 surfaces as a per-run upload failure first). Either way the - // critical invariant is the existing blob bytes are untouched. - expect([409, 500]).toContain(res.status); + expect(res.status).toBe(409); expect(blobStore.get(originalBlobKey)).toEqual(originalIterBytes); }); + + it("rejects a foreign request ID without exposing its status or uploading new blobs", async () => { + const reqs = makeRequestCollection(); + const fixture = makeFixtureRun("foreign-run"); + reqs.docs.set(fixture._id, { ...fixture, projectId: "another-project", run: { ...fixture.run, status: "processing" } }); + const archive = await buildTarGz({ + [`${fixture._id}/iteration-1.har`]: "must not upload", + [`${fixture._id}/run.yaml`]: yamlStringify(fixture), + }); + const res = await request(buildApp(reqs)) + .post(`/api/v1/runs/upload?projectId=${TEST_PROJECT_ID}`) + .attach("archive", archive, "archive.tar.gz"); + expect(res.status).toBe(404); + expect(res.body.error).toBe("Not found"); + expect(JSON.stringify(res.body)).not.toMatch(/processing|existingStatus|another-project/); + expect(blobStore.size).toBe(0); + expect(reqs.insertOne).not.toHaveBeenCalled(); + }); + + it("never imports serialized artifact URLs without the corresponding archive bytes", async () => { + const reqs = makeRequestCollection(); + const fixture = makeFixtureRun("own-import"); + const foreign = blobUrl("snapshots", "foreign-request/private-artifact"); + const archive = await buildTarGz({ + [`${fixture._id}/run.yaml`]: yamlStringify({ + ...fixture, run: { + ...fixture.run, logsUrl: foreign, harUrl: foreign, rawChatUrl: foreign, + videoUrls: [foreign], setupVideoUrls: [foreign], + turns: fixture.run.turns.map(turn => ({ + ...turn, snapshotUrl: foreign, harUrl: foreign, rawChatUrl: foreign, + chatResultUrl: foreign, toolCallsUrl: foreign, atifUrl: foreign, videoUrls: [foreign], + })), + }, + }), + }); + const res = await request(buildApp(reqs)) + .post(`/api/v1/runs/upload?projectId=${TEST_PROJECT_ID}`) + .attach("archive", archive, "archive.tar.gz"); + expect(res.status).toBe(201); + const imported: unknown = reqs.docs.get(fixture._id); + expect(JSON.stringify(imported)).not.toContain("foreign-request"); + expect(imported).toMatchObject({ run: { turns: [{ snapshotUrl: "" }] } }); + expect(blobStore.size).toBe(0); + }); }); describe("run import/export — round-trip (export → import)", () => { diff --git a/apps/api/src/routes/requests/atif.test.ts b/apps/api/src/routes/requests/atif.test.ts index 1dd79ba17..31df1472f 100644 --- a/apps/api/src/routes/requests/atif.test.ts +++ b/apps/api/src/routes/requests/atif.test.ts @@ -10,9 +10,14 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import supertest from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; -import { createAllMockDependencies } from "../../test-helpers.js"; +import { createAllMockDependencies, mockRouteAuthorization } from "../../test-helpers.js"; import { readableFrom, rawParser, blobUrl, VARIANTS, rewireBlobMocks } from "./test-blob-helpers.js"; +vi.mock("../../auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("../../test-helpers.js")).mockControllerUserAccessMiddleware, +})); + vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), })); @@ -49,6 +54,7 @@ describe("ATIF endpoints", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/routes/requests/cancel.test.ts b/apps/api/src/routes/requests/cancel.test.ts index 31701f812..4b60d09f3 100644 --- a/apps/api/src/routes/requests/cancel.test.ts +++ b/apps/api/src/routes/requests/cancel.test.ts @@ -7,6 +7,7 @@ * - POST /api/v1/requests/bulk-cancel */ import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockRouteAuthorization } from "../../test-helpers.js"; import express, { type Express } from "express"; import request from "supertest"; import { OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; @@ -78,6 +79,7 @@ function buildApp( heartbeatStore: ReturnType, ): Express { const app = express(); + mockRouteAuthorization(app); app.use(express.json()); const ctx: any = { diff --git a/apps/api/src/routes/requests/har.test.ts b/apps/api/src/routes/requests/har.test.ts index 67d1d8112..8b272758c 100644 --- a/apps/api/src/routes/requests/har.test.ts +++ b/apps/api/src/routes/requests/har.test.ts @@ -10,9 +10,14 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import supertest from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; -import { createAllMockDependencies } from "../../test-helpers.js"; +import { createAllMockDependencies, mockRouteAuthorization } from "../../test-helpers.js"; import { readableFrom, rawParser, blobUrl, VARIANTS, rewireBlobMocks } from "./test-blob-helpers.js"; +vi.mock("../../auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("../../test-helpers.js")).mockControllerUserAccessMiddleware, +})); + vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), })); @@ -49,6 +54,7 @@ describe("HAR endpoints", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/routes/requests/index.ts b/apps/api/src/routes/requests/index.ts index 0d719ffbb..917d63c5f 100644 --- a/apps/api/src/routes/requests/index.ts +++ b/apps/api/src/routes/requests/index.ts @@ -499,6 +499,7 @@ apiRoute(ctx.app, ctx.registry, { storageAccountName: ctx.storageAccountName, }); const result = await resolveCodebaseSpec(spec, { + projectId, codebaseStore: ctx.codebaseStore, codebaseRevisionStore: ctx.codebaseRevisionStore, codebaseResolver: ctx.codebaseResolver, @@ -2464,6 +2465,11 @@ async function finalizePendingRun( blobServiceClient: BlobServiceClient, projectId: string, ): Promise { + const existingRun = await ctx.requestCollection.findOne({ _id: prefix }, { timeoutMS: 5_000 }); + if (existingRun) { + if (existingRun.projectId !== projectId) throw new ImportError(404, "Not found"); + throw new ImportError(409, `Run with ID '${prefix}' already exists`, { existingStatus: existingRun.run?.status }); + } if (run.uploadErrors.length > 0) { throw new ImportError(500, `Blob upload failures: ${run.uploadErrors.join("; ")}`); } @@ -2513,15 +2519,6 @@ async function finalizePendingRun( ); } - const existingRun = await ctx.requestCollection.findOne({ _id: runDoc._id }); - if (existingRun) { - throw new ImportError( - 409, - `Run with ID '${runDoc._id}' already exists`, - { existingStatus: existingRun.run?.status }, - ); - } - // logs.jsonl was staged to a tmp file during the streaming pass because // its canonical blob path embeds the per-attempt `run._id` which only // becomes known after yaml parse. Upload it now to the right destination @@ -2551,24 +2548,30 @@ async function finalizePendingRun( } // Resolve per-turn URLs from the artifacts we streamed. - const turns = (runState.turns ?? []).map(t => ({ ...t })); - for (const turn of turns) { - const N = turn.iteration; - const snap = run.artifacts.get(`iteration-${N}.tar.gz`); - if (snap) (turn as { snapshotUrl?: string }).snapshotUrl = snap; - const har = run.artifacts.get(`iteration-${N}.har`); - if (har) (turn as { harUrl?: string }).harUrl = har; - const chat = run.artifacts.get(`iteration-${N}.chat-export.json`); - if (chat) (turn as { rawChatUrl?: string }).rawChatUrl = chat; - const chatRes = run.artifacts.get(`iteration-${N}.chat-result.json`); - if (chatRes) (turn as { chatResultUrl?: string }).chatResultUrl = chatRes; - const tc = run.artifacts.get(`iteration-${N}.tool-calls.jsonl`); - if (tc) (turn as { toolCallsUrl?: string }).toolCallsUrl = tc; - } + // Serialized URLs are untrusted references, not imported artifacts. Only + // bytes uploaded by this import may back an authenticated artifact proxy. + const turns = (runState.turns ?? []).map(turn => { + const imported = { ...turn, snapshotUrl: run.artifacts.get(`iteration-${turn.iteration}.tar.gz`) ?? "" }; + delete imported.videoUrls; + for (const [key, filename] of [ + ["harUrl", `iteration-${turn.iteration}.har`], + ["rawChatUrl", `iteration-${turn.iteration}.chat-export.json`], + ["chatResultUrl", `iteration-${turn.iteration}.chat-result.json`], + ["toolCallsUrl", `iteration-${turn.iteration}.tool-calls.jsonl`], + ] as const) { + delete imported[key]; + const url = run.artifacts.get(filename); + if (url) imported[key] = url; + } + return imported; + }); + delete runState.logsUrl; + delete runState.harUrl; + delete runState.rawChatUrl; + delete runState.videoUrls; + delete runState.setupVideoUrls; const topHar = run.artifacts.get("run.har"); - if (topHar) runState.harUrl = topHar; const topChat = run.artifacts.get("run.chat-export.json"); - if (topChat) runState.rawChatUrl = topChat; // Materialize the task-prompt entity for this run. The submission flow // (POST /api/v1/requests) goes through `taskPromptStore.findOrCreate` so @@ -2607,9 +2610,9 @@ async function finalizePendingRun( ...(runState as unknown as RunState), // Per-attempt `run._id` is preserved from the yaml (NOT clobbered // with the request `_id`) so retries / history demotion still work. - logsUrl: newLogsUrl ?? runState.logsUrl, - ...(runState.harUrl ? { harUrl: runState.harUrl } : {}), - ...(runState.rawChatUrl ? { rawChatUrl: runState.rawChatUrl } : {}), + ...(newLogsUrl ? { logsUrl: newLogsUrl } : {}), + ...(topHar ? { harUrl: topHar } : {}), + ...(topChat ? { rawChatUrl: topChat } : {}), turns, // Denormalize duration so imported finished runs are immediately // sortable by duration (before the backfill migration runs). @@ -2670,6 +2673,13 @@ async function streamArchiveImport( } const prefix = header.name.slice(0, slash); const filename = header.name.slice(slash + 1); + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(prefix)) { + extractErrors.push("Invalid archive run identifier"); + entryStream.on("end", next); + entryStream.on("error", next); + entryStream.resume(); + return; + } let run = pending.get(prefix); if (!run) { @@ -2724,8 +2734,10 @@ async function streamArchiveImport( const tags: Record = { requestId: prefix }; if (target.iteration !== undefined) tags.iteration = String(target.iteration); - blockBlobClient - .uploadStream(entryStream as Readable, undefined, undefined, { + ctx.requestCollection.findOne({ _id: prefix }, { timeoutMS: 5_000 }) + .then(async existing => { + if (existing) return false; + await blockBlobClient.uploadStream(entryStream as Readable, undefined, undefined, { blobHTTPHeaders: { blobContentType: target.contentType }, tags, // Refuse to clobber an existing blob. If a run with this `_id` was @@ -2735,8 +2747,16 @@ async function streamArchiveImport( // and the per-run cleanup just deletes whatever fresh blobs we did // manage to write, leaving the existing run intact. conditions: { ifNoneMatch: "*" }, + }); + return true; }) - .then(() => { + .then(uploaded => { + if (!uploaded) { + entryStream.on("end", next); + entryStream.on("error", next); + entryStream.resume(); + return; + } run!.artifacts.set(filename, blockBlobClient.url); run!.uploadedBlobs.push(blockBlobClient); if (target.isIteration) run!.iterationCount++; @@ -2989,7 +3009,7 @@ apiRoute(ctx.app, ctx.registry, { } const reports = await ctx.reportCollection - .find({ requestId: id }) + .find({ requestId: id, projectId: run.projectId }) .sort({ createdAt: -1 }) .toArray(); @@ -3022,7 +3042,7 @@ apiRoute(ctx.app, ctx.registry, { const history = await listHistoricalRuns({ runsCollection: ctx.runsCollection }, id); const current = request.run ? [request.run] : []; // Combine current + history; current is always the highest attemptNumber - res.json([...current, ...history]); + res.json([...current, ...history.filter(run => run.projectId === request.projectId)]); }, }); @@ -3047,7 +3067,7 @@ apiRoute(ctx.app, ctx.registry, { return; } const historical = await getHistoricalRun({ runsCollection: ctx.runsCollection }, runId); - if (!historical || historical.requestId !== id) { + if (!historical || historical.requestId !== id || historical.projectId !== request.projectId) { res.status(404).json({ error: "Run not found for this request" }); return; } diff --git a/apps/api/src/routes/requests/logs.test.ts b/apps/api/src/routes/requests/logs.test.ts index 44153cb70..395bdbe3e 100644 --- a/apps/api/src/routes/requests/logs.test.ts +++ b/apps/api/src/routes/requests/logs.test.ts @@ -5,9 +5,14 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import request from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; -import { createAllMockDependencies } from "../../test-helpers.js"; +import { createAllMockDependencies, mockRouteAuthorization } from "../../test-helpers.js"; import type { LogEvent } from "shared"; +vi.mock("../../auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("../../test-helpers.js")).mockControllerUserAccessMiddleware, +})); + // ─── Module stubs (required before app import resolves) ──────────────────── vi.mock("db-migrations/check-migrations", () => ({ @@ -55,6 +60,7 @@ describe("SSE log endpoints — blob replay", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/routes/requests/resolve-run.test.ts b/apps/api/src/routes/requests/resolve-run.test.ts index 0ff6177f9..ec492cd03 100644 --- a/apps/api/src/routes/requests/resolve-run.test.ts +++ b/apps/api/src/routes/requests/resolve-run.test.ts @@ -8,9 +8,14 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import supertest from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; -import { createAllMockDependencies } from "../../test-helpers.js"; +import { createAllMockDependencies, mockRouteAuthorization } from "../../test-helpers.js"; import { readableFrom, blobUrl, rewireBlobMocks } from "./test-blob-helpers.js"; +vi.mock("../../auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("../../test-helpers.js")).mockControllerUserAccessMiddleware, +})); + vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), })); @@ -47,6 +52,7 @@ describe("resolveRunForRequest (via per-run endpoints)", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/routes/requests/resolve-run.ts b/apps/api/src/routes/requests/resolve-run.ts index 9aa7ad29f..447904569 100644 --- a/apps/api/src/routes/requests/resolve-run.ts +++ b/apps/api/src/routes/requests/resolve-run.ts @@ -22,7 +22,7 @@ export async function resolveRunForRequest( return { run: request.run, request }; } const historical = await getHistoricalRun({ runsCollection: ctx.runsCollection }, runId); - if (!historical || historical.requestId !== requestId) { + if (!historical || historical.requestId !== requestId || historical.projectId !== request.projectId) { return { error: "Run not found for this request", status: 404 }; } return { run: historical, request }; diff --git a/apps/api/src/routes/requests/snapshots.test.ts b/apps/api/src/routes/requests/snapshots.test.ts index c485aeffc..ecfc494ad 100644 --- a/apps/api/src/routes/requests/snapshots.test.ts +++ b/apps/api/src/routes/requests/snapshots.test.ts @@ -10,9 +10,14 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import supertest from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; -import { createAllMockDependencies } from "../../test-helpers.js"; +import { createAllMockDependencies, mockRouteAuthorization } from "../../test-helpers.js"; import { readableFrom, blobUrl, VARIANTS, rewireBlobMocks } from "./test-blob-helpers.js"; +vi.mock("../../auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("../../test-helpers.js")).mockControllerUserAccessMiddleware, +})); + vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), })); @@ -49,6 +54,7 @@ describe("Snapshots endpoints", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/routes/requests/tool-calls.test.ts b/apps/api/src/routes/requests/tool-calls.test.ts index bd0ce96bc..160b8a599 100644 --- a/apps/api/src/routes/requests/tool-calls.test.ts +++ b/apps/api/src/routes/requests/tool-calls.test.ts @@ -10,9 +10,14 @@ import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import supertest from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; -import { createAllMockDependencies } from "../../test-helpers.js"; +import { createAllMockDependencies, mockRouteAuthorization } from "../../test-helpers.js"; import { readableFrom, blobUrl, VARIANTS, rewireBlobMocks } from "./test-blob-helpers.js"; +vi.mock("../../auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("../../test-helpers.js")).mockControllerUserAccessMiddleware, +})); + vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), })); @@ -49,6 +54,7 @@ describe("Tool-calls endpoints", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/routes/requests/video.test.ts b/apps/api/src/routes/requests/video.test.ts index a737d036a..d4f7af2ae 100644 --- a/apps/api/src/routes/requests/video.test.ts +++ b/apps/api/src/routes/requests/video.test.ts @@ -12,9 +12,14 @@ import { get } from "node:http"; import supertest from "supertest"; import { app, _injectTestDependencies } from "../../index.js"; import { useTestServer } from "../../test-server.js"; -import { createAllMockDependencies } from "../../test-helpers.js"; +import { createAllMockDependencies, mockRouteAuthorization } from "../../test-helpers.js"; import { readableFrom, rawParser, blobUrl, VARIANTS, rewireBlobMocks } from "./test-blob-helpers.js"; +vi.mock("../../auth/middleware.js", async importOriginal => ({ + ...await importOriginal(), + createUserAccessMiddleware: (await import("../../test-helpers.js")).mockControllerUserAccessMiddleware, +})); + vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001", "002"], pending: [] }), })); @@ -51,6 +56,7 @@ describe("Video endpoints", () => { beforeAll(() => { mocks = createAllMockDependencies(); _injectTestDependencies(mocks); + mockRouteAuthorization(app); }); beforeEach(() => { diff --git a/apps/api/src/routes/resources.test.ts b/apps/api/src/routes/resources.test.ts index f9aa73223..4695d1182 100644 --- a/apps/api/src/routes/resources.test.ts +++ b/apps/api/src/routes/resources.test.ts @@ -15,6 +15,7 @@ import { } from "shared"; import type { RouteContext } from "../route-context.js"; import { registerResourcesRoutes } from "./resources.js"; +import { mockRouteAuthorization } from "../test-helpers.js"; type UnknownRecord = Record; @@ -133,6 +134,7 @@ function fakeCollection(seed: T[] = []) { function buildApp() { const app = express(); + mockRouteAuthorization(app); app.use(express.json()); const resourceCollection = fakeCollection(); const revisionCollection = fakeCollection(); diff --git a/apps/api/src/routes/secrets.test.ts b/apps/api/src/routes/secrets.test.ts new file mode 100644 index 000000000..9d781d814 --- /dev/null +++ b/apps/api/src/routes/secrets.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import express from "express"; +import request from "supertest"; +import { MongoClient } from "mongodb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Authorization } from "../auth/authorization.js"; +import { AccessError } from "../auth/admin-operations.js"; +import { registerSecretsRoutes } from "./secrets.js"; + +const client = new MongoClient("mongodb://127.0.0.1:1"); +const fetchMock = vi.fn(); +beforeEach(() => { + vi.stubEnv("TOKEN_MANAGER_URL", "http://token-manager.test"); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + fetchMock.mockReset(); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); + +function app() { + const server = express(); + server.use(express.json()); + registerSecretsRoutes({ app: server, db: client.db("unused_unit_fixture"), authEnabled: true }); + server.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error instanceof AccessError ? error.status : 500).json({ error: error.message }); + }); + return server; +} + +const routes = [ + ["get", "/keys"], ["post", "/keys"], ["post", "/keys/preview"], + ["get", "/keys/example"], ["put", "/keys/example"], ["delete", "/keys/example"], + ["post", "/keys/example/validate"], ["get", "/accounts"], ["post", "/accounts"], + ["get", "/accounts/example"], ["put", "/accounts/example"], ["delete", "/accounts/example"], +] as const; + +describe("Global Secrets proxy", () => { + it.each(routes)("requires platform authorization for %s %s without a project", async (method, path) => { + const guard = vi.spyOn(Authorization.prototype, "guard").mockResolvedValue(1); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ managed: true }), { headers: { "content-type": "application/json" } })); + const response = await request(app())[method](`/api/v1${path}`).send(method === "get" ? undefined : { comment: "metadata" }); + expect(response.status).toBe(200); + expect(response.headers["cache-control"]).toBe("no-store"); + expect(guard).toHaveBeenCalledWith(expect.anything(), method, `/api/v1${path}`, "platform"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith(`http://token-manager.test/api/v1${path}`, expect.objectContaining({ + method: method.toUpperCase(), signal: expect.any(AbortSignal), + })); + }); + + it.each([401, 403])("denies unauthorized access with HTTP %s before contacting Token Manager", async status => { + vi.spyOn(Authorization.prototype, "guard").mockRejectedValue(new AccessError(status, "access_denied", "Platform administration required")); + expect((await request(app()).post("/api/v1/keys").send({})).status).toBe(status); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("keeps protected routes registered and returns an actionable503 when Token Manager is not configured", async () => { + vi.stubEnv("TOKEN_MANAGER_URL", ""); + vi.spyOn(Authorization.prototype, "guard").mockResolvedValue(1); + const response = await request(app()).get("/api/v1/keys"); + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ code: "token_manager_unavailable", error: expect.stringContaining("TOKEN_MANAGER_URL") }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(["fetch", "body", "timeout"])("surfaces a sanitized503 for an upstream %s failure without replaying a create", async failure => { + vi.spyOn(Authorization.prototype, "guard").mockResolvedValue(1); + if (failure === "body") { + const response = new Response("unused"); + vi.spyOn(response, "text").mockRejectedValue(new Error("sensitive upstream detail")); + fetchMock.mockResolvedValue(response); + } else { + fetchMock.mockRejectedValue(failure === "timeout" + ? new DOMException("sensitive upstream detail", "TimeoutError") : new TypeError("sensitive upstream detail")); + } + const response = await request(app()).post("/api/v1/keys").send({ value: "test-only-never-log" }); + expect(response.status).toBe(503); + expect(response.body).toEqual({ + code: "token_manager_unavailable", + error: "Secrets service is unavailable. Check that Token Manager and its secret store are running, then try again.", + }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(console.error).toHaveBeenCalledWith("[api] Token Manager request failed", "POST", "/api/v1/keys"); + }); + + it("preserves upstream validation errors instead of claiming success", async () => { + vi.spyOn(Authorization.prototype, "guard").mockResolvedValue(1); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: "Invalid key type" }), { status: 400, headers: { "content-type": "application/json" } })); + const response = await request(app()).post("/api/v1/keys").send({}); + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: "Invalid key type" }); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it.each([500, 502, 503])("normalizes upstream HTTP %s secret-store failures without exposing internal errors", async status => { + vi.spyOn(Authorization.prototype, "guard").mockResolvedValue(1); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ error: "fetch failed: internal secret-store details" }), { status })); + const response = await request(app()).post("/api/v1/keys").send({}); + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ code: "token_manager_unavailable", error: expect.stringContaining("secret store") }); + expect(JSON.stringify(response.body)).not.toContain("internal"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/api/src/routes/secrets.ts b/apps/api/src/routes/secrets.ts index e7766f7e2..a5303b6ae 100644 --- a/apps/api/src/routes/secrets.ts +++ b/apps/api/src/routes/secrets.ts @@ -3,34 +3,54 @@ import type { Request, Response, NextFunction } from "express"; import type { RouteContext } from "../route-context.js"; +import { Authorization } from "../auth/authorization.js"; // ============================================================================= // Token Manager proxy (admin CRUD - excludes /acquire which is worker-only) // ============================================================================= -export function registerSecretsRoutes(ctx: RouteContext): void { +export function registerSecretsRoutes(ctx: Pick): void { const TOKEN_MANAGER_URL = process.env.TOKEN_MANAGER_URL || ""; if (!TOKEN_MANAGER_URL) { console.log("[api] Token Manager proxy disabled (TOKEN_MANAGER_URL not set)"); - return; } const proxyToTokenManager = async (req: Request, res: Response, next: NextFunction) => { try { + await new Authorization(ctx.db, ctx.authEnabled).guard(req, req.method.toLowerCase(), req.path, "platform"); + res.set("Cache-Control", "no-store"); + if (!TOKEN_MANAGER_URL) { + res.status(503).json({ + error: "Secrets service is unavailable. Configure TOKEN_MANAGER_URL and start Token Manager.", + code: "token_manager_unavailable", + }); + return; + } const targetUrl = `${TOKEN_MANAGER_URL}${req.originalUrl}`; const headers: Record = { "content-type": "application/json" }; const fetchOpts: RequestInit = { method: req.method, headers, + signal: AbortSignal.timeout(30_000), }; if (req.method !== "GET" && req.method !== "HEAD") { fetchOpts.body = JSON.stringify(req.body); } - const upstream = await fetch(targetUrl, fetchOpts); - const contentType = upstream.headers.get("content-type") || "application/json"; - const body = await upstream.text(); - res.status(upstream.status).set("content-type", contentType).send(body); + try { + // Callers own retries; never replay a credential mutation in this proxy. + const upstream = await fetch(targetUrl, fetchOpts); + const contentType = upstream.headers.get("content-type") || "application/json"; + const body = await upstream.text(); + if (upstream.status >= 500) throw new Error("Token Manager returned a server error"); + res.status(upstream.status).set("content-type", contentType).send(body); + } catch { + console.error("[api] Token Manager request failed", req.method, req.path); + res.status(503).json({ + error: "Secrets service is unavailable. Check that Token Manager and its secret store are running, then try again.", + code: "token_manager_unavailable", + }); + } } catch (error) { next(error); } @@ -56,5 +76,5 @@ export function registerSecretsRoutes(ctx: RouteContext): void { // NOTE: GET /api/v1/accounts/:id/secrets is intentionally NOT proxied. // Key-updaters call token-manager directly (ClusterIP) for secrets. - console.log(`[api] Token Manager proxy enabled → ${TOKEN_MANAGER_URL}`); + if (TOKEN_MANAGER_URL) console.log(`[api] Token Manager proxy enabled → ${TOKEN_MANAGER_URL}`); } diff --git a/apps/api/src/routes/system.ts b/apps/api/src/routes/system.ts index 212679250..ad63d383e 100644 --- a/apps/api/src/routes/system.ts +++ b/apps/api/src/routes/system.ts @@ -99,13 +99,16 @@ apiRoute(ctx.app, ctx.registry, { buildTime: z.string(), environment: z.string(), strictAgentCapabilities: z.boolean(), + authEnabled: z.boolean(), }), handler: async (_req, res) => { + res.setHeader("Cache-Control", "no-store"); res.json({ commit: (process.env.GIT_COMMIT || "development"), buildTime: (process.env.BUILD_TIME || new Date().toISOString()), environment: (process.env.SCOPE_ENVIRONMENT || "production"), strictAgentCapabilities: ctx.strictAgentCapabilities, + authEnabled: ctx.authEnabled !== false, }); }, }); diff --git a/apps/api/src/routes/task-prompts.integration.test.ts b/apps/api/src/routes/task-prompts.integration.test.ts new file mode 100644 index 000000000..e04338052 --- /dev/null +++ b/apps/api/src/routes/task-prompts.integration.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import express from "express"; +import request from "supertest"; +import { MongoClient, type Db } from "mongodb"; +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { TaskPromptStore, type UserDocument } from "shared"; +import { Authorization } from "../auth/authorization.js"; +import { AccessError, AdminOperations } from "../auth/admin-operations.js"; +import type { RouteContext } from "../route-context.js"; +import { registerTaskPromptsRoutes } from "./task-prompts.js"; +import { registerPromptFeaturesRoutes } from "./prompt-features.js"; + +const llm = vi.hoisted(() => ({ + generateTaskPrompt: vi.fn(), + extractPromptFeatures: vi.fn(), + generatePromptFeaturePrompt: vi.fn(), +})); +vi.mock("../task-prompt-llm.js", () => ({ isTaskPromptLlmAvailable: () => true, generateTaskPrompt: llm.generateTaskPrompt })); +vi.mock("../prompt-feature-llm.js", () => ({ + isLlmAvailable: () => true, + extractPromptFeatures: llm.extractPromptFeatures, + generatePromptFeaturePrompt: llm.generatePromptFeaturePrompt, +})); + +/** Regression: AI endpoints must never feed another project's data into LLM input or persisted output. */ +const uri = process.env.AUTH_TEST_MONGO_URI; +describe.runIf(Boolean(uri))("AI task-prompt endpoints are project-isolated", () => { + const database = `ai-isolation-test-${randomUUID()}`; + const app = express(); + let client: MongoClient; + let db: Db; + let operations: AdminOperations; + let projectA: string, projectB: string, memberId: string, platformId: string; + let promptA: string, promptB: string; + + beforeAll(async () => { + if (!uri) throw new Error("AUTH_TEST_MONGO_URI is required"); + client = await new MongoClient(uri).connect(); + db = client.db(database); + operations = new AdminOperations(db); + const authority = new Authorization(db); + app.use(express.json()); + app.use((req, _res, next) => { + // Harness starts AFTER bearer verification; canonical membership authority is real. + req.user = { id: memberId, role: "user", isAuthenticated: true }; + req.auth = { token: `h.${Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 300 })).toString("base64url")}.s` }; + next(); + }); + app.locals.authorization = () => authority; + const taskPromptCollection = db.collection("task-prompts"); + const ctx = { + app, registry: new OpenAPIRegistry(), db, + taskPromptCollection, promptFeatureCollection: db.collection("prompt-features"), + taskPromptStore: new TaskPromptStore(taskPromptCollection as never), + } as unknown as RouteContext; + registerTaskPromptsRoutes(ctx); + registerPromptFeaturesRoutes(ctx); + app.use((error: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(error instanceof AccessError ? error.status : 500).json({ error: error.message }); + }); + }); + + beforeEach(async () => { + vi.clearAllMocks(); + llm.generateTaskPrompt.mockResolvedValue({ tasks: ["generated"] }); + llm.generatePromptFeaturePrompt.mockResolvedValue({ prompt: "generated" }); + llm.extractPromptFeatures.mockImplementation(async (_text: string, configs: { id: string }[]) => ({ + results: configs.map(c => ({ featureId: c.id, detected: true, evaluated: true })), suggestedFeatures: [], + })); + projectA = randomUUID(); projectB = randomUUID(); memberId = randomUUID(); platformId = randomUUID(); + promptA = randomUUID(); promptB = randomUUID(); + const now = new Date(); + await db.collection("users").insertMany([ + { _id: memberId, idp: "entra", idpTenant: "fixture", idpSubject: memberId, role: "user", createdAt: now, updatedAt: now }, + { _id: platformId, idp: "entra", idpTenant: "fixture", idpSubject: platformId, role: "admin", createdAt: now, updatedAt: now }, + ]); + await db.collection("projects").insertMany([{ _id: projectA, name: "A" }, { _id: projectB, name: "B" }] as never); + await db.collection("task-prompts").insertMany([ + { _id: promptA, projectId: projectA, keyId: "a", type: "select", text: "A secret prompt", createdAt: now }, + { _id: promptB, projectId: projectB, keyId: "b", type: "select", text: "B secret prompt", createdAt: now }, + ] as never); + await db.collection("prompt-features").insertMany([ + { id: "feature_a", projectId: projectA, prompt: "A feature?", createdAt: now }, + { id: "feature_b", projectId: projectB, prompt: "B feature?", createdAt: now }, + ] as never); + // Member of project A only. + await operations.execute({ + _id: randomUUID(), actor: platformId, reason: "Isolation fixture", + collection: "project_memberships", targetId: `${projectA}:${memberId}`, expectedRevision: 0, + change: { role: "contributor" }, unset: ["revokedAt"], initial: { projectId: projectA, userId: memberId, createdByUserId: platformId }, + }); + }); + afterAll(async () => { if (db) await db.dropDatabase(); await client?.close(); }); + + it("generate sends only the authorized project's prompts to the LLM", async () => { + const res = await request(app).post(`/api/v1/task-prompts/generate?projectId=${projectA}`).send({ description: "x" }); + expect(res.status).toBe(200); + const existing: string[] = llm.generateTaskPrompt.mock.calls[0][1]; + expect(existing).toContain("A secret prompt"); + expect(existing).not.toContain("B secret prompt"); + }); + + it("extract-features sends and persists only the prompt's project features", async () => { + const res = await request(app).post(`/api/v1/task-prompts/${promptA}/extract-features?force=true`).send({}); + expect(res.status).toBe(200); + const configs: { id: string }[] = llm.extractPromptFeatures.mock.calls[0][1]; + expect(configs.map(c => c.id)).toEqual(["feature_a"]); + const stored = await db.collection("task-prompts").findOne({ _id: promptA as never }); + expect(stored?.features.map((f: { featureId: string }) => f.featureId)).toEqual(["feature_a"]); + }); + + it("prompt-feature generate/extract endpoints only read the authorized project's features", async () => { + expect((await request(app).post(`/api/v1/prompt-features/generate-prompt?projectId=${projectA}`).send({ behavior: "b" })).status).toBe(200); + expect(llm.generatePromptFeaturePrompt.mock.calls[0][1].map((f: { id: string }) => f.id)).toEqual(["feature_a"]); + expect((await request(app).post(`/api/v1/prompt-features/extract-from-text?projectId=${projectA}`).send({ text: "t" })).status).toBe(200); + expect(llm.extractPromptFeatures.mock.calls[0][1].map((f: { id: string }) => f.id)).toEqual(["feature_a"]); + }); + + it("denies the foreign project and requires a project on generate", async () => { + expect((await request(app).post(`/api/v1/task-prompts/generate?projectId=${projectB}`).send({ description: "x" })).status).toBe(404); + expect((await request(app).post(`/api/v1/task-prompts/${promptB}/extract-features?force=true`).send({})).status).toBe(404); + expect((await request(app).post("/api/v1/task-prompts/generate").send({ description: "x" })).status).toBe(400); + expect(llm.generateTaskPrompt).not.toHaveBeenCalled(); + expect(llm.extractPromptFeatures).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/routes/task-prompts.ts b/apps/api/src/routes/task-prompts.ts index f626ea079..4677c1a35 100644 --- a/apps/api/src/routes/task-prompts.ts +++ b/apps/api/src/routes/task-prompts.ts @@ -29,6 +29,7 @@ apiRoute(ctx.app, ctx.registry, { path: "/api/v1/task-prompts/generate", tags: ["Task Prompts"], summary: "Generate task prompts", + query: ProjectIdQuerySchema, body: z.object({ description: z.string().optional(), existingPrompt: z.string().optional(), @@ -46,9 +47,10 @@ apiRoute(ctx.app, ctx.registry, { return; } - // Fetch recent task prompts as context (avoid duplicates) + // Fetch the project's recent task prompts as context (avoid duplicates) const recentPrompts = await ctx.taskPromptCollection .find({ + projectId: getQueryProjectId(req), deletedAt: { $exists: false }, $or: [{ type: "select" }, { type: { $exists: false } }], }) @@ -123,14 +125,8 @@ apiRoute(ctx.app, ctx.registry, { // GET /api/v1/task-prompts/:id/content — get the resolved plain-text body // (inline or downloaded from blob). Used by workers to fetch AGENTS.md / task text. -// -// NOTE: This returns any prompt body by ID with no authorization check. That is -// consistent with the rest of the API, which has no auth/tenancy model today -// (see index.ts: just cors() + express.json()). Because prompts are globally -// content-addressed and deduplicated via taskPromptStore.findOrCreate, this route -// implicitly assumes a trusted-network, single-tenant deployment. If auth or -// multi-tenancy is ever introduced, revisit this: the dedup store is NOT tenant-safe -// and this endpoint would leak prompt bodies across tenants. +// Project authorization is enforced by the route guard, which resolves the +// prompt's projectId from the document before the handler runs. apiRoute(ctx.app, ctx.registry, { method: "get", path: "/api/v1/task-prompts/:id/content", @@ -257,15 +253,15 @@ apiRoute(ctx.app, ctx.registry, { return; } - // Scope candidate features to the prompt's own type: task prompts get task - // features; AGENTS.md prompts get agents.md features. Absent type ⇒ select. + // Scope candidate features to the prompt's own project and type: task prompts + // get task features; AGENTS.md prompts get agents.md features. Absent type ⇒ select. const promptType = taskPrompt.type ?? "select"; const typeFilter: Record = promptType === "select" ? { $or: [{ type: "select" }, { type: { $exists: false } }] } : { type: promptType }; const allFeatures = await ctx.promptFeatureCollection - .find({ deletedAt: { $exists: false }, ...typeFilter }) + .find({ projectId: taskPrompt.projectId, deletedAt: { $exists: false }, ...typeFilter }) .toArray(); const featureConfigs = allFeatures.map(f => ({ id: f.id, prompt: f.prompt })); diff --git a/apps/api/src/routes/users.test.ts b/apps/api/src/routes/users.test.ts index 0ba2a5e91..f5357f5be 100644 --- a/apps/api/src/routes/users.test.ts +++ b/apps/api/src/routes/users.test.ts @@ -7,6 +7,7 @@ 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"; +import { Authorization } from "../auth/authorization.js"; vi.mock("db-migrations/check-migrations", () => ({ checkMigrations: vi.fn().mockResolvedValue({ ready: true, applied: ["001"], pending: [] }), @@ -51,6 +52,9 @@ const resolver = { describe("/api/v1/users/me", () => { beforeEach(() => { vi.resetAllMocks(); + vi.spyOn(Authorization.prototype, "capabilities").mockResolvedValue({ + platformAdmin: false, reportsEnabled: false, memberships: [], + }); provider.verifyAccessToken.mockResolvedValue(identity); resolver.resolveExisting.mockResolvedValue(principal); resolver.enrollOnLogin.mockResolvedValue(principal); @@ -80,6 +84,7 @@ describe("/api/v1/users/me", () => { displayName: "Test User", idp: "entra", idpTenant: "tenant-1", + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [] }, }); expect(provider.verifyAccessToken).toHaveBeenCalledExactlyOnceWith("token"); expect(resolver.resolveExisting).toHaveBeenCalledExactlyOnceWith(identity); diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index 1e6dae4ff..4af834d9f 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -3,10 +3,11 @@ import { z } from "zod"; import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; -import { AuthError } from "shared"; +import { AuthError, AccessCapabilitiesSchema } from "shared"; import type { AuthenticatedUser } from "../auth/types.js"; import { apiRoute } from "../openapi/api-route.js"; import type { RouteContext } from "../route-context.js"; +import { Authorization } from "../auth/authorization.js"; extendZodWithOpenApi(z); @@ -18,10 +19,11 @@ const UserMeResponseSchema = z displayName: z.string().optional(), idp: z.string().optional(), idpTenant: z.string().optional(), + capabilities: AccessCapabilitiesSchema, }) .openapi("UserMeResponse"); -function userMeResponse(user: AuthenticatedUser): z.infer { +function userMeResponse(user: AuthenticatedUser): Omit, "capabilities"> { return { id: user.id, role: user.role, @@ -57,7 +59,7 @@ export function registerUsersRoutes(ctx: RouteContext): void { } const user = await resolver.resolveExisting(req.auth.identity); req.user = user; - res.json(userMeResponse(user)); + res.json({ ...userMeResponse(user), capabilities: await new Authorization(ctx.db).capabilities(req) }); }, }); @@ -67,7 +69,8 @@ export function registerUsersRoutes(ctx: RouteContext): void { 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.", + description: "JIT-enroll the authenticated user with the default user role, refresh their profile and lastLoginAt, and warm the access cache. Login never grants administrative privileges. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.", + body: z.object({}).strict().optional(), response: UserMeResponseSchema, successStatus: 200, errorResponses: { @@ -86,7 +89,7 @@ export function registerUsersRoutes(ctx: RouteContext): void { } const user = await resolver.enrollOnLogin(req.auth.identity, req.auth.token); req.user = user; - res.json(userMeResponse(user)); + res.json({ ...userMeResponse(user), capabilities: await new Authorization(ctx.db).capabilities(req) }); }, }); } diff --git a/apps/api/src/test-helpers.ts b/apps/api/src/test-helpers.ts index 023c46b73..31f089927 100644 --- a/apps/api/src/test-helpers.ts +++ b/apps/api/src/test-helpers.ts @@ -2,6 +2,8 @@ // Licensed under the MIT License. import { vi } from "vitest"; +import type { Express, RequestHandler } from "express"; +import { performance } from "node:perf_hooks"; import type { Collection, Db, @@ -10,6 +12,21 @@ import type { import type { BlobStorage } from "shared"; import type { TestDependencies } from "./index.js"; +/** Controller-only harness: authorization itself is covered separately. */ +export function mockRouteAuthorization(app: Express) { + const guard = vi.fn(async () => performance.now() + 20_000); + app.locals.authorization = () => ({ guard }); + return guard; +} + +/** Controller-only fixture; never used by real-auth integration/acceptance tests. */ +export function mockControllerUserAccessMiddleware(): RequestHandler { + return (req, _res, next) => { + req.user = { id: "unit-controller-user", role: "user", isAuthenticated: true }; + next(); + }; +} + // --------------------------------------------------------------------------- // Mock MongoDB collection // --------------------------------------------------------------------------- diff --git a/apps/api/src/utils/codebase-helpers.ts b/apps/api/src/utils/codebase-helpers.ts index 1cb5d504d..424189526 100644 --- a/apps/api/src/utils/codebase-helpers.ts +++ b/apps/api/src/utils/codebase-helpers.ts @@ -76,6 +76,7 @@ export async function downloadCodebaseArchive( } export interface CodebaseSpecResolveContext { + projectId: string; codebaseStore: CodebaseStore; codebaseRevisionStore: CodebaseRevisionStore; codebaseResolver: CodebaseResolver; @@ -103,18 +104,21 @@ export async function resolveCodebaseSpec( // 1) `{slug}@r{N}` ref. const parsed = parseCodebaseRevisionRef(trimmed); if (parsed && parsed.revisionNumber !== undefined) { - const revision = await ctx.codebaseRevisionStore.getByRef(trimmed); - if (!revision) return { error: `Codebase revision not found: ${trimmed}` }; + const revision = await ctx.codebaseRevisionStore.getByRef(ctx.projectId, trimmed); + if (!revision || revision.projectId !== ctx.projectId) return { error: `Codebase revision not found: ${trimmed}` }; return { revisionId: revision._id, ref: revision.ref }; } // 2) Raw revision id (UUID). const byId = await ctx.codebaseRevisionStore.get(trimmed); - if (byId) return { revisionId: byId._id, ref: byId.ref }; + if (byId) { + if (byId.projectId !== ctx.projectId) return { error: "Codebase revision not found" }; + return { revisionId: byId._id, ref: byId.ref }; + } // 3) Bare slug → latest (archive) or resolve-at-submit (git). - const codebase = await ctx.codebaseStore.getBySlug(trimmed); - if (!codebase) return { error: `Codebase not found: ${trimmed}` }; + const codebase = await ctx.codebaseStore.getBySlug(ctx.projectId, trimmed); + if (!codebase || codebase.projectId !== ctx.projectId) return { error: `Codebase not found: ${trimmed}` }; if (codebase.sourceType === "git") { try { @@ -135,7 +139,7 @@ export async function resolveCodebaseSpec( // Archive codebase — must have an existing revision. const latest = await ctx.codebaseRevisionStore.getLatest(codebase._id); - if (!latest) { + if (!latest || latest.projectId !== ctx.projectId || latest.codebaseId !== codebase._id) { return { error: `Codebase "${codebase.slug}" has no revisions — upload an archive first` }; } return { revisionId: latest._id, ref: latest.ref }; diff --git a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap index 66f3e414c..37f30830d 100644 --- a/apps/cli/src/__snapshots__/cli-commands.test.ts.snap +++ b/apps/cli/src/__snapshots__/cli-commands.test.ts.snap @@ -2,6 +2,90 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` { + "access": { + "subcommands": { + "disable-user": { + "options": [ + "--operation-id", + "--revision", + "--url", + ], + }, + "enroll": { + "options": [ + "--url", + ], + }, + "find-member": { + "options": [ + "--url", + ], + }, + "find-user": { + "options": [ + "--url", + ], + }, + "me": { + "options": [ + "--url", + ], + }, + "members": { + "options": [ + "--url", + ], + }, + "platform-admins": { + "options": [ + "--cursor", + "--limit", + "--url", + ], + }, + "projects": { + "options": [ + "--url", + ], + }, + "promote-admin": { + "options": [ + "--operation-id", + "--revision", + "--url", + ], + }, + "revoke-member": { + "options": [ + "--operation-id", + "--revision", + "--url", + ], + }, + "search-members": { + "options": [ + "--cursor", + "--limit", + "--url", + ], + }, + "search-users": { + "options": [ + "--cursor", + "--limit", + "--url", + ], + }, + "set-member": { + "options": [ + "--operation-id", + "--revision", + "--role", + "--url", + ], + }, + }, + }, "agent": { "subcommands": { "delete": { @@ -449,6 +533,7 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "options": [ "--description", "--name", + "--operation-id", "--output", "--url", "--use", @@ -456,6 +541,8 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` }, "delete": { "options": [ + "--operation-id", + "--revision", "--url", ], }, @@ -468,7 +555,9 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` }, "restore": { "options": [ + "--operation-id", "--output", + "--revision", "--url", ], }, @@ -482,7 +571,9 @@ exports[`CLI command registration > full command tree matches snapshot 1`] = ` "options": [ "--description", "--name", + "--operation-id", "--output", + "--revision", "--url", ], }, diff --git a/apps/cli/src/commands/access.test.ts b/apps/cli/src/commands/access.test.ts new file mode 100644 index 000000000..bff08e934 --- /dev/null +++ b/apps/cli/src/commands/access.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Command } from "commander"; +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerAccessCommands } from "./access.js"; +import { resetApiClient } from "../utils/api-client.js"; + +describe("access CLI operations", () => { + const fetchMock = vi.fn(); + const output = vi.spyOn(console, "log").mockImplementation(() => {}); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + let originalExitCode: typeof process.exitCode; + let payload: unknown; + + afterAll(() => { output.mockRestore(); errors.mockRestore(); }); + beforeEach(() => { + originalExitCode = process.exitCode; + process.exitCode = undefined; + resetApiClient(); + vi.stubEnv("SCOPE_TOKEN", "fixture-raw-idp-bearer"); + vi.stubGlobal("fetch", fetchMock); + fetchMock.mockReset(); + payload = undefined; + output.mockClear(); + errors.mockClear(); + }); + afterEach(() => { + resetApiClient(); + process.exitCode = originalExitCode; + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + async function run(...args: string[]) { + const program = new Command().exitOverride(); + registerAccessCommands(program); + await program.parseAsync(["access", ...args, "--url", "http://localhost:3101"], { from: "user" }); + } + function respond(status: number, body: unknown) { + fetchMock.mockImplementationOnce(async input => { + if (!(input instanceof Request)) throw new Error("Expected Request"); + payload = input.body ? await input.clone().json() : undefined; + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + }); + } + function request(): Request { + const input = fetchMock.mock.calls[0][0]; + if (!(input instanceof Request)) throw new Error("CLI transport must send a Request"); + return input; + } + + it.each(["operation", "reconcile"])("does not expose the retired %s command or issue an HTTP request", async name => { + await expect(run(name, "fixture-operation")).rejects.toMatchObject({ code: "commander.unknownCommand" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("forwards the unchanged raw bearer and explicit idempotency key with the expected revision", async () => { + respond(200, { operationId: "reviewed-assignment", state: "completed", revision: 5 }); + await run("set-member", "project", "user", "--role", "reader", "--revision", "4", "--operation-id", "reviewed-assignment"); + expect(request().headers.get("Authorization")).toBe("Bearer fixture-raw-idp-bearer"); + expect(request().headers.get("Idempotency-Key")).toBe("reviewed-assignment"); + expect(request().method).toBe("PUT"); + expect(payload).toEqual({ role: "reader", revision: 4 }); + expect(errors).toHaveBeenCalledWith(expect.stringContaining("reviewed-assignment")); + expect(process.exitCode).toBeUndefined(); + }); + + it("prints a generated operation key before sending the mutation", async () => { + fetchMock.mockImplementationOnce(async input => { + if (!(input instanceof Request)) throw new Error("Expected Request"); + const key = input.headers.get("Idempotency-Key"); + if (!key) throw new Error("Missing idempotency key"); + expect(key).toMatch(/^[0-9a-f-]{36}$/); + expect(errors).toHaveBeenCalledWith(expect.stringContaining(key)); + return Response.json({ operationId: key, state: "completed", revision: 1 }); + }); + await run("disable-user", "user", "--revision", "0"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("reports an uncertain 202 without claiming completion or retrying it", async () => { + respond(202, { operationId: "uncertain", state: "pending", revision: 1 }); + await run("revoke-member", "project", "user", "--revision", "0", "--operation-id", "uncertain"); + expect(process.exitCode).toBe(2); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(errors).toHaveBeenCalledWith(expect.stringContaining("No completion claimed")); + expect(output).toHaveBeenCalledWith(expect.stringContaining('"state": "pending"')); + }); + + it("surfaces conflicts without retrying or logging successful output", async () => { + respond(409, { error: "idempotency_conflict" }); + await run("set-member", "project", "user", "--role", "admin", "--revision", "0", "--operation-id", "reused"); + expect(process.exitCode).toBe(1); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(errors).toHaveBeenCalledWith(expect.stringContaining("HTTP 409")); + expect(output).not.toHaveBeenCalled(); + }); + + it("rejects invalid revisions before sending any request", async () => { + await run("disable-user", "user", "--revision", "-1"); + expect(process.exitCode).toBe(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("reads own account using GET and never implicitly enrolls", async () => { + respond(200, { id: "fixture" }); + await run("me"); + expect(request().method).toBe("GET"); + expect(request().headers.has("Idempotency-Key")).toBe(false); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + it("looks up only the exact enrolled member UUID within the explicit project", async () => { + respond(200, { id: "target" }); + await run("find-member", "project/a", "target/uuid"); + expect(new URL(request().url).pathname).toBe("/api/v1/projects/project%2Fa/members/lookup/target%2Fuuid"); + expect(request().method).toBe("GET"); + expect(request().headers.get("Authorization")).toBe("Bearer fixture-raw-idp-bearer"); + expect(request().headers.has("Idempotency-Key")).toBe(false); + }); + it.each([403, 404])("surfaces a denied or foreign project roster (%s) without enrollment or retry", async status => { + respond(status, { error: "not_permitted" }); + await run("members", "foreign-project"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(process.exitCode).toBe(1); + expect(output).not.toHaveBeenCalled(); + }); + + it("searches the platform-only enrolled-user directory with paging and the raw bearer", async () => { + respond(200, { items: [], nextCursor: "next-user" }); + await run("search-users", "Bob + Example", "--limit", "5", "--cursor", "previous-user"); + const url = new URL(request().url); + expect(url.pathname).toBe("/api/v1/admin/users/search"); + expect(url.searchParams.get("q")).toBe("Bob + Example"); + expect(url.searchParams.get("limit")).toBe("5"); + expect(url.searchParams.get("cursor")).toBe("previous-user"); + expect(request().method).toBe("GET"); + expect(request().headers.get("Authorization")).toBe("Bearer fixture-raw-idp-bearer"); + }); + + it("searches enrolled membership candidates through an explicit project with paging and the raw bearer", async () => { + respond(200, { items: [], nextCursor: "next-user" }); + await run("search-members", "project/a", "Alice + Example", "--limit", "5", "--cursor", "previous-user"); + const url = new URL(request().url); + expect(url.pathname).toBe("/api/v1/projects/project%2Fa/members/search"); + expect(url.searchParams.get("q")).toBe("Alice + Example"); + expect(url.searchParams.get("limit")).toBe("5"); + expect(url.searchParams.get("cursor")).toBe("previous-user"); + expect(request().method).toBe("GET"); + expect(request().headers.get("Authorization")).toBe("Bearer fixture-raw-idp-bearer"); + expect(request().headers.has("Idempotency-Key")).toBe(false); + }); + + it.each([403, 404])("reports a denied project member search (%s) without using the admin directory", async status => { + respond(status, { error: "not_permitted" }); + await run("search-members", "project", "alice"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(new URL(request().url).pathname).toBe("/api/v1/projects/project/members/search"); + expect(process.exitCode).toBe(1); + expect(output).not.toHaveBeenCalled(); + }); + + it("rejects an invalid directory page size before sending a request", async () => { + await run("search-users", "--limit", "51"); + await run("search-members", "project", "--limit", "0"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it("lists only platform administrators using server-side cursor pagination", async () => { + respond(200, { items: [], nextCursor: "next-admin" }); + await run("platform-admins", "--limit", "5", "--cursor", "previous-admin"); + const url = new URL(request().url); + expect(url.pathname).toBe("/api/v1/admin/platform-admins"); + expect(url.searchParams.get("limit")).toBe("5"); + expect(url.searchParams.get("cursor")).toBe("previous-admin"); + expect(request().method).toBe("GET"); + expect(request().headers.has("Idempotency-Key")).toBe(false); + }); + + it("promotes an exact enrolled Scope user with revision and stable operation ID, not arbitrary role input", async () => { + respond(200, { operationId: "approved-promotion", state: "completed", revision: 5 }); + await run("promote-admin", "scope/user", "--revision", "4", "--operation-id", "approved-promotion"); + expect(new URL(request().url).pathname).toBe("/api/v1/admin/users/scope%2Fuser/promote"); + expect(request().method).toBe("POST"); + expect(request().headers.get("Idempotency-Key")).toBe("approved-promotion"); + expect(payload).toEqual({ revision: 4 }); + expect(process.exitCode).toBeUndefined(); + }); + + it("preserves a pending administrator promotion instead of retrying with a new operation", async () => { + respond(202, { operationId: "pending-promotion", state: "pending", revision: 1 }); + await run("promote-admin", "user", "--revision", "0", "--operation-id", "pending-promotion"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(process.exitCode).toBe(2); + expect(errors).toHaveBeenCalledWith(expect.stringContaining("No completion claimed")); + }); + + it("rejects invalid administrator revisions and page sizes locally", async () => { + await run("promote-admin", "user", "--revision", "1.5"); + await run("platform-admins", "--limit", "0"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); +}); diff --git a/apps/cli/src/commands/access.ts b/apps/cli/src/commands/access.ts new file mode 100644 index 000000000..838179645 --- /dev/null +++ b/apps/cli/src/commands/access.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { Command } from "commander"; +import { apiFetch } from "../utils/api-client.js"; +import { getDefaultApiUrl } from "../utils/shared.js"; + +interface AccessOptions { url: string; operationId?: string; revision?: string; role?: string; cursor?: string; limit?: string } + +export function registerAccessCommands(program: Command): void { + const access = program.command("access").description("Account and project membership administration (raw IdP SCOPE_TOKEN)"); + function command(name: string, description: string): Command { + return access.command(name).description(description).option("-u, --url ", "API base URL", getDefaultApiUrl()); + } + function mutation(command: Command): Command { + return command.option("--operation-id ", "Stable idempotency key (reuse only for identical input)") + .requiredOption("--revision ", "Expected current target revision (0 for a new membership)"); + } + function revision(options: AccessOptions): number { + const value = Number(options.revision); + if (!Number.isSafeInteger(value) || value < 0) throw new Error("Revision must be a nonnegative integer"); + return value; + } + async function request(options: AccessOptions, path: string, method = "GET", body?: unknown): Promise { + const id = options.operationId ?? randomUUID(); + if (method !== "GET" && path !== "/users/me") console.error(`Operation ID: ${id} (reuse only for identical input)`); + const response = await apiFetch(options.url, path, { + method, headers: { "Content-Type": "application/json", ...(method !== "GET" ? { "Idempotency-Key": id } : {}) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const data: unknown = response.status === 204 ? null : await response.json(); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${JSON.stringify(data)}`); + console.log(JSON.stringify(data, null, 2)); + if (response.status === 202) { + console.error("Operation pending: retry only identical input with the same operation ID, or give that ID to an operator for repair. No completion claimed."); + process.exitCode = 2; + } + } + function action(run: (...args: Args) => Promise): (...args: Args) => Promise { + return async (...args: Args) => { + try { await run(...args); } + catch (error) { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; } + }; + } + function search(options: AccessOptions, path: string, query?: string): Promise { + const limit = Number(options.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("Limit must be an integer from 1 to 50"); + const params = new URLSearchParams({ q: query ?? "", limit: String(limit) }); + if (options.cursor) params.set("cursor", options.cursor); + return request(options, `${path}?${params}`); + } + command("me", "Read own account and current capabilities").action(action((options: AccessOptions) => request(options, "/users/me"))); + command("enroll", "Explicitly enroll after a successful IdP login").action(action((options: AccessOptions) => request(options, "/users/me", "POST"))); + command("find-user ", "Exact enrolled Scope UUID lookup (platform admin)") + .action(action((userId: string, options: AccessOptions) => request(options, `/admin/users?userId=${encodeURIComponent(userId)}`))); + command("find-member ", "Exact enrolled Scope UUID lookup for adding a member (project or platform admin)") + .action(action((projectId: string, userId: string, options: AccessOptions) => + request(options, `/projects/${encodeURIComponent(projectId)}/members/lookup/${encodeURIComponent(userId)}`))); + command("search-users [query]", "Search enrolled platform users by name, email or Scope UUID (platform admin)") + .option("--cursor ", "Continue from the previous response's nextCursor") + .option("--limit ", "Maximum users per page (1-50)", "20") + .action(action((query: string | undefined, options: AccessOptions) => search(options, "/admin/users/search", query))); + command("search-members [query]", "Find enrolled users by name, email or Scope UUID to add to a project (project or platform admin)") + .option("--cursor ", "Continue from the previous response's nextCursor") + .option("--limit ", "Maximum users per page (1-50)", "20") + .action(action((projectId: string, query: string | undefined, options: AccessOptions) => + search(options, `/projects/${encodeURIComponent(projectId)}/members/search`, query))); + command("projects", "Platform-only project control-plane inventory") + .action(action((options: AccessOptions) => request(options, "/projects?controlPlane=true&includeDeleted=true"))); + command("platform-admins", "List platform administrators, including disabled accounts and pending changes") + .option("--cursor ", "Continue from the previous response's nextCursor") + .option("--limit ", "Maximum administrators per page (1-50)", "20") + .action(action((options: AccessOptions) => { + const limit = Number(options.limit); + if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error("Limit must be an integer from 1 to 50"); + const params = new URLSearchParams({ limit: String(limit) }); + if (options.cursor) params.set("cursor", options.cursor); + return request(options, `/admin/platform-admins?${params}`); + })); + mutation(command("promote-admin ", "Grant platform-wide administration to an enabled enrolled user (no project membership)")) + .action(action((userId: string, options: AccessOptions) => + request(options, `/admin/users/${encodeURIComponent(userId)}/promote`, "POST", { revision: revision(options) }))); + command("members ", "Membership roster (project or platform admin)") + .action(action((projectId: string, options: AccessOptions) => request(options, `/projects/${encodeURIComponent(projectId)}/members`))); + mutation(command("set-member ", "Explicitly assign or change a project membership") + .requiredOption("--role ", "reader, contributor or admin")) + .action(action((projectId: string, userId: string, options: AccessOptions) => + request(options, `/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(userId)}`, "PUT", { role: options.role, revision: revision(options) }))); + mutation(command("revoke-member ", "Soft-revoke a membership")) + .action(action((projectId: string, userId: string, options: AccessOptions) => + request(options, `/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(userId)}`, "DELETE", { revision: revision(options) }))); + mutation(command("disable-user ", "Disable an ordinary user; administrator disable and demotion remain operator-only")) + .action(action((userId: string, options: AccessOptions) => request(options, `/admin/users/${encodeURIComponent(userId)}/disable`, "POST", { revision: revision(options) }))); +} diff --git a/apps/cli/src/commands/project.test.ts b/apps/cli/src/commands/project.test.ts new file mode 100644 index 000000000..769aec7a1 --- /dev/null +++ b/apps/cli/src/commands/project.test.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Command } from "commander"; +import { projectPermissions } from "shared/rbac"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerProjectCommands } from "./project.js"; +import { resetApiClient } from "../utils/api-client.js"; + +const config = vi.hoisted(() => ({ + getSelectedProjectId: vi.fn(), resolveProjectId: vi.fn(), setSelectedProjectId: vi.fn(), +})); +vi.mock("../utils/config.js", () => config); + +describe("project CLI explicit membership selection", () => { + const fetchMock = vi.fn(); + const project = { _id: "fixture-project", id: "fixture-project", name: "Fixture", createdAt: "2026-01-01T00:00:00Z" }; + + beforeEach(() => { + vi.clearAllMocks(); + resetApiClient(); + vi.stubEnv("SCOPE_TOKEN", "fixture-raw-idp-bearer"); + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(process, "exit").mockImplementation(code => { throw new Error(`exit:${code}`); }); + }); + afterEach(() => { + resetApiClient(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + async function run(...args: string[]) { + const program = new Command().exitOverride(); + registerProjectCommands(program); + await program.parseAsync(["project", ...args, "--url", "http://localhost:3101"], { from: "user" }); + } + + it("selects a newly created project only after confirming its creator membership", async () => { + fetchMock.mockResolvedValueOnce(Response.json(project, { status: 201 })).mockResolvedValueOnce(Response.json({ authEnabled: true })).mockResolvedValueOnce(Response.json({ + capabilities: { + platformAdmin: true, reportsEnabled: false, + memberships: [{ projectId: project.id, role: "admin", revision: 1, permissions: projectPermissions("admin") }], + }, + })); + await run("create", "--name", "Fixture", "--use"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(config.setSelectedProjectId).toHaveBeenCalledWith(project.id); + }); + + it("does not select an idempotently replayed creation after membership was revoked", async () => { + fetchMock.mockResolvedValueOnce(Response.json(project, { status: 201 })).mockResolvedValueOnce(Response.json({ authEnabled: true })).mockResolvedValueOnce(Response.json({ + capabilities: { platformAdmin: true, reportsEnabled: false, memberships: [] }, + })); + await expect(run("create", "--name", "Fixture", "--use")).rejects.toThrow("exit:1"); + expect(config.setSelectedProjectId).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("explicit active membership")); + }); + + it("does not select content for a no-membership platform administrator", async () => { + fetchMock.mockResolvedValueOnce(Response.json(project)).mockResolvedValueOnce(Response.json({ authEnabled: true })).mockResolvedValueOnce(Response.json({ + capabilities: { platformAdmin: true, reportsEnabled: false, memberships: [] }, + })); + await expect(run("use", project.id)).rejects.toThrow("exit:1"); + expect(config.setSelectedProjectId).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("explicit active membership")); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it.each(["reader", "contributor", "admin"] as const)("selects a project with an explicit %s membership using GET only", async role => { + fetchMock.mockResolvedValueOnce(Response.json(project)).mockResolvedValueOnce(Response.json({ authEnabled: true })).mockResolvedValueOnce(Response.json({ + capabilities: { + platformAdmin: false, reportsEnabled: false, + memberships: [{ projectId: project.id, role, revision: 1, permissions: projectPermissions(role) }], + }, + })); + await run("use", project.id); + expect(config.setSelectedProjectId).toHaveBeenCalledWith(project.id); + for (const [input] of fetchMock.mock.calls) { + if (!(input instanceof Request)) throw new Error("Expected the centralized raw-bearer transport"); + expect(input.method).toBe("GET"); + expect(input.headers.get("Authorization")).toBe("Bearer fixture-raw-idp-bearer"); + } + }); + + it("creates a project without changing saved selection unless requested", async () => { + fetchMock.mockResolvedValueOnce(Response.json(project, { status: 201 })); + await run("create", "--name", "Fixture", "--output", "json"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(config.setSelectedProjectId).not.toHaveBeenCalled(); + }); + + it.each([["use", "fixture-project"], ["create", "--name", "Fixture", "--use"]])( + "selects without an identity only when the API explicitly reports auth off: %j", async (...args) => { + vi.stubEnv("SCOPE_TOKEN", ""); + fetchMock.mockResolvedValueOnce(Response.json(project)).mockResolvedValueOnce(Response.json({ authEnabled: false })); + await run(...args); + expect(config.setSelectedProjectId).toHaveBeenCalledWith(project.id); + expect(fetchMock).toHaveBeenCalledTimes(2); + for (const [input] of fetchMock.mock.calls) { + if (!(input instanceof Request)) throw new Error("Expected centralized transport"); + expect(input.headers.has("Authorization")).toBe(false); + expect(input.url).not.toContain("/users/me"); + } + }, + ); + + it("does not infer anonymous mode from a missing or malformed API mode", async () => { + fetchMock.mockResolvedValueOnce(Response.json(project)).mockResolvedValueOnce(Response.json({})); + await expect(run("use", project.id)).rejects.toThrow("exit:1"); + expect(config.setSelectedProjectId).not.toHaveBeenCalled(); + }); + + it("does not claim success for a pending project creation", async () => { + fetchMock.mockResolvedValueOnce(Response.json({ operationId: "uncertain-create", state: "pending" }, { status: 202 })); + await expect(run("create", "--name", "Fixture")).rejects.toThrow("exit:1"); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(console.log).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalledWith(expect.any(String), expect.stringContaining("Completion is not confirmed")); + expect(config.setSelectedProjectId).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/commands/project.ts b/apps/cli/src/commands/project.ts index 7b8eaab42..bdfbf537e 100644 --- a/apps/cli/src/commands/project.ts +++ b/apps/cli/src/commands/project.ts @@ -2,6 +2,8 @@ // Licensed under the MIT License. import { Command } from "commander"; +import { randomUUID } from "node:crypto"; +import { AccessCapabilitiesSchema, ApiAuthModeSchema } from "shared/rbac"; import type { ProjectDocument } from "shared"; import { configureHelp } from "../utils/helpFormatter.js"; import { dimTimestamp, errorText, label, successText, value, warnBanner } from "../utils/style.js"; @@ -13,6 +15,7 @@ import { getSelectedProjectId, resolveProjectId, setSelectedProjectId } from ".. /** Project as returned by the API — dates arrive as JSON strings, `id` mirrors `_id`. */ type ProjectApiDocument = Omit & { + revision?: number; id?: string; createdAt: string; updatedAt?: string; @@ -28,6 +31,18 @@ async function readError(response: Response): Promise { return body.error ?? JSON.stringify(body); } +function mutationHeaders(operationId?: string): Record { + const id = operationId ?? randomUUID(); + console.error(dimTimestamp(`Operation ID: ${id} (reuse only for identical input)`)); + return { "Content-Type": "application/json", "Idempotency-Key": id }; +} + +async function requireCompleted(response: Response): Promise { + if (response.status !== 202) return; + const pending: { operationId?: string } = await response.json(); + throw new Error(`Operation ${pending.operationId ?? "unknown"} is pending; retry only identical input with the same ID, or give it to an operator for repair. Completion is not confirmed.`); +} + function projectId(project: ProjectApiDocument): string { return project.id ?? project._id; } @@ -68,6 +83,20 @@ async function fetchProject(baseUrl: string, id: string): Promise { + const version = await apiFetch(baseUrl, "/version"); + if (!version.ok) throw new Error(await readError(version)); + const mode: unknown = await version.json(); + if (!ApiAuthModeSchema.parse(mode).authEnabled) return; + const own = await apiFetch(baseUrl, "/users/me"); + if (!own.ok) throw new Error(await readError(own)); + const account: { capabilities?: unknown } = await own.json(); + const capabilities = AccessCapabilitiesSchema.parse(account.capabilities); + if (!capabilities.memberships.some(membership => membership.projectId === id)) { + throw new Error("Selecting project content requires an explicit active membership, including for platform admins"); + } +} + export function registerProjectCommands(program: Command): void { const project = program .command("project") @@ -118,6 +147,7 @@ export function registerProjectCommands(program: Command): void { .requiredOption("--name ", "Display name") .option("--description ", "Description") .option("--use", "Select the new project as the active project after creating it") + .option("--operation-id ", "Stable idempotency key for identical retries") .option("-u, --url ", "API base URL", getDefaultApiUrl()), ).action(async (options) => { const format = (options.output ?? "table") as OutputFormat; @@ -126,13 +156,16 @@ export function registerProjectCommands(program: Command): void { if (options.description) body.description = options.description; const response = await apiFetch(options.url, "/projects", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: mutationHeaders(options.operationId), body: JSON.stringify(body), }); if (!response.ok) throw new Error(await readError(response)); + await requireCompleted(response); const created = (await response.json()) as ProjectApiDocument; - - if (options.use) setSelectedProjectId(projectId(created)); + if (options.use) { + await requireMembership(options.url, projectId(created)); + setSelectedProjectId(projectId(created)); + } if (isMachineReadable(format)) { console.log(formatData([created], projectFields(), format)); @@ -141,11 +174,9 @@ export function registerProjectCommands(program: Command): void { console.log(successText(`Project "${created.name}" created.`)); console.log(`${label("ID:")} ${value(projectId(created))}`); console.log(`${label("Name:")} ${value(created.name)}`); - if (options.use) { - console.log(successText(`Selected "${created.name}" as the active project.`)); - } else { - console.log(dimTimestamp(`Select it with \`${getCliName()} project use ${projectId(created)}\`.`)); - } + console.log(dimTimestamp(options.use + ? "Selected as the active project." + : `Select with \`${getCliName()} project use ${projectId(created)}\`.`)); } catch (error) { console.error(errorText("Error:"), error instanceof Error ? error.message : error); process.exit(1); @@ -165,6 +196,7 @@ export function registerProjectCommands(program: Command): void { console.error(errorText(`Project not found: ${id}`)); process.exit(1); } + await requireMembership(options.url, id); setSelectedProjectId(projectId(found)); console.log(successText(`Active project set to "${found.name}" (${projectId(found)}).`)); } catch (error) { @@ -222,23 +254,27 @@ export function registerProjectCommands(program: Command): void { .argument("", "Project ID to update") .option("--name ", "New display name") .option("--description ", "New description") + .requiredOption("--revision ", "Expected project revision") + .option("--operation-id ", "Stable idempotency key for identical retries") .option("-u, --url ", "API base URL", getDefaultApiUrl()), ).action(async (id: string, options) => { const format = (options.output ?? "table") as OutputFormat; try { - const patch: { name?: string; description?: string } = {}; + const patch: { name?: string; description?: string; revision?: number } = {}; if (options.name !== undefined) patch.name = options.name; if (options.description !== undefined) patch.description = options.description; if (Object.keys(patch).length === 0) { console.error(errorText("Error: provide --name and/or --description to update")); process.exit(1); } + patch.revision = Number(options.revision); const response = await apiFetch(options.url, `/projects/${encodeURIComponent(id)}`, { method: "PATCH", - headers: { "Content-Type": "application/json" }, + headers: mutationHeaders(options.operationId), body: JSON.stringify(patch), }); if (!response.ok) throw new Error(await readError(response)); + await requireCompleted(response); const updated = (await response.json()) as ProjectApiDocument; if (isMachineReadable(format)) { console.log(formatData([updated], projectFields(getSelectedProjectId()), format)); @@ -256,11 +292,16 @@ export function registerProjectCommands(program: Command): void { .command("delete") .description("Soft-delete a project (restore later with `project restore`)") .argument("", "Project ID to delete") + .requiredOption("--revision ", "Expected project revision") + .option("--operation-id ", "Stable idempotency key for identical retries") .option("-u, --url ", "API base URL", getDefaultApiUrl()) .action(async (id: string, options) => { try { - const response = await apiFetch(options.url, `/projects/${encodeURIComponent(id)}`, { method: "DELETE" }); + const response = await apiFetch(options.url, `/projects/${encodeURIComponent(id)}`, { + method: "DELETE", headers: mutationHeaders(options.operationId), body: JSON.stringify({ revision: Number(options.revision) }), + }); if (!response.ok) throw new Error(await readError(response)); + await requireCompleted(response); // Clear the persisted selection if we just deleted the active project. if (getSelectedProjectId() === id) setSelectedProjectId(undefined); console.log(successText(`Project ${id} deleted.`)); @@ -276,12 +317,17 @@ export function registerProjectCommands(program: Command): void { .command("restore") .description("Restore a soft-deleted project") .argument("", "Project ID to restore") + .requiredOption("--revision ", "Expected project revision") + .option("--operation-id ", "Stable idempotency key for identical retries") .option("-u, --url ", "API base URL", getDefaultApiUrl()), ).action(async (id: string, options) => { const format = (options.output ?? "table") as OutputFormat; try { - const response = await apiFetch(options.url, `/projects/${encodeURIComponent(id)}/restore`, { method: "POST" }); + const response = await apiFetch(options.url, `/projects/${encodeURIComponent(id)}/restore`, { + method: "POST", headers: mutationHeaders(options.operationId), body: JSON.stringify({ revision: Number(options.revision) }), + }); if (!response.ok) throw new Error(await readError(response)); + await requireCompleted(response); const restored = (await response.json()) as ProjectApiDocument; if (isMachineReadable(format)) { console.log(formatData([restored], projectFields(getSelectedProjectId()), format)); diff --git a/apps/cli/src/commands/report.ts b/apps/cli/src/commands/report.ts index 1dce6f3b6..94dc0bd45 100644 --- a/apps/cli/src/commands/report.ts +++ b/apps/cli/src/commands/report.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { Command } from "commander"; +import { REPORT_GENERATION_ENABLED } from "shared/rbac"; import EventSource from "eventsource"; import { configureHelp } from "../utils/helpFormatter.js"; import { banner, colorLevel, dimTimestamp, errorText, successText, label, value, warnBanner } from "../utils/style.js"; @@ -25,12 +26,17 @@ configureHelp(report); report .command("generate") - .description("Generate a report for a benchmark run") + .description("Unavailable: new report generation is disabled during the RBAC MVP") .requiredOption("-i, --id ", "Run ID to generate a report for") .option("-u, --url ", "API base URL", getDefaultApiUrl()) .option("--stream", "Stream report generation logs in real time", true) .option("--no-stream", "Do not stream logs after submission") .action(async (options) => { + if (!REPORT_GENERATION_ENABLED) { + console.error(errorText("New report generation is disabled during the RBAC MVP. Stored reports remain readable.")); + process.exitCode = 1; + return; + } try { const response = await apiFetch(options.url, `/reports`, { method: "POST", diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 399f12266..8fd9b2112 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -24,6 +24,7 @@ import { registerInsightCommands } from "./commands/insight.js"; import { registerTaskPromptCommands } from "./commands/task-prompt.js"; import { registerProfileCommands } from "./commands/profile.js"; import { registerProjectCommands } from "./commands/project.js"; +import { registerAccessCommands } from "./commands/access.js"; import { registerSecretCommands } from "./commands/secret.js"; import { registerUpdateCommand } from "./commands/update.js"; import { checkForUpdates } from "./utils/update-check.js"; @@ -69,6 +70,7 @@ configureHelp(program); // Register all command groups registerSecretCommands(program); registerProjectCommands(program); +registerAccessCommands(program); registerRunCommands(program); registerCriteriaCommands(program); registerPromptFeatureCommands(program); diff --git a/apps/cli/src/utils/api-client.ts b/apps/cli/src/utils/api-client.ts index 2ab2dba76..946567423 100644 --- a/apps/cli/src/utils/api-client.ts +++ b/apps/cli/src/utils/api-client.ts @@ -431,6 +431,9 @@ const SKIP_AUTH_HEADER = "x-scope-skip-auth"; * never clobbers a caller-supplied `Authorization`. */ const authHook: BeforeRequestHook = async ({ request }) => { + if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !request.headers.has("Idempotency-Key")) { + request.headers.set("Idempotency-Key", crypto.randomUUID()); + } const skip = request.headers.has(SKIP_AUTH_HEADER); request.headers.delete(SKIP_AUTH_HEADER); if (skip) return; diff --git a/apps/portal/Dockerfile b/apps/portal/Dockerfile index 35e8d91f8..df2877b6c 100644 --- a/apps/portal/Dockerfile +++ b/apps/portal/Dockerfile @@ -10,7 +10,11 @@ RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install WORKDIR /app COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ COPY apps/portal/package.json apps/portal/ -RUN pnpm install --frozen-lockfile --filter portal --prod=false +COPY packages/shared/package.json packages/shared/ +RUN pnpm install --frozen-lockfile --filter portal... --prod=false +COPY tsconfig.base.json ./ +COPY packages/shared/ packages/shared/ +RUN pnpm --filter shared build # --- Dev stage: Vite dev server with HMR --- # Built from `deps` (not `builder`) so the dev image skips the production build. diff --git a/apps/portal/docker-entrypoint.sh b/apps/portal/docker-entrypoint.sh index 7ea04f3b3..a947e118f 100755 --- a/apps/portal/docker-entrypoint.sh +++ b/apps/portal/docker-entrypoint.sh @@ -12,9 +12,9 @@ CONFIG_PATH="/usr/share/nginx/html/config.js" # Auth feature toggle for this environment (integration/production). Auth is ON # by default (secure by default); set SCOPE_AUTH_ENABLED=false on the overlay to -# disable sign-in until that environment's API verifies tokens. Coerced to a +# disable sign-in and RBAC together with the API's matching setting. Coerced to a # real JS boolean literal for window.__SCOPE_CONFIG__.authEnabled. -case "$(printf '%s' "${SCOPE_AUTH_ENABLED:-true}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in +case "$(printf '%s' "${SCOPE_AUTH_ENABLED:-true}" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" in false|0|no|off) AUTH_ENABLED=false ;; *) AUTH_ENABLED=true ;; esac diff --git a/apps/portal/package.json b/apps/portal/package.json index 36e053ac6..9eedbe0b8 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -47,6 +47,7 @@ "remark-gfm": "^4.0.0", "remark-github-markdown-alerts": "^1.2.2", "sonner": "^1.7.0", + "shared": "workspace:*", "tailwind-merge": "^2.6.0", "yaml": "^2.8.3" }, diff --git a/apps/portal/src/App.tsx b/apps/portal/src/App.tsx index 26d5f1d74..58bc94aa2 100644 --- a/apps/portal/src/App.tsx +++ b/apps/portal/src/App.tsx @@ -65,6 +65,7 @@ import { FeatureRoute } from "@/components/FeatureRoute"; import { ProjectGate } from "@/components/ProjectGate"; import { HomeRoute } from "@/components/HomeRoute"; import { useFavicon } from "@/hooks/useFavicon"; +import { AccessAdministration } from "@/pages/AccessAdministration"; export function App() { useFavicon(); @@ -116,6 +117,7 @@ export function App() { } /> } /> } /> + } /> } /> }> } /> diff --git a/apps/portal/src/components/AccessOperation.tsx b/apps/portal/src/components/AccessOperation.tsx new file mode 100644 index 000000000..993006162 --- /dev/null +++ b/apps/portal/src/components/AccessOperation.tsx @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { api, ApiError } from "@/lib/api"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import type { ProjectRole } from "shared/rbac"; + +interface AccessChange { + id: string; + path: string; + method: "PUT" | "DELETE" | "POST"; + body: { revision: number; role?: ProjectRole }; + description: string; +} +interface OperationResult { operationId: string; state: "completed" | "pending"; revision: number } +export function useAccessOperation(onExecute: () => void) { + const queryClient = useQueryClient(); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const [pending, setPending] = useState(); + const refresh = () => Promise.all( + ["access-projects", "access-members", "access-users", "access-platform-admins", "projects"] + .map(key => queryClient.invalidateQueries({ queryKey: [key] })), + ); + const execute = async (change: AccessChange) => { + setBusy(true); setPending(change); onExecute(); setError(""); setMessage(""); + try { + const result = await api.accessRequest(change.path, change.method, change.body, change.id); + if (result.state !== "completed") throw new Error("Access change is pending operator repair"); + setPending(undefined); setMessage(`${change.description} completed.`); + } catch (cause) { + const terminal = cause instanceof ApiError && cause.status >= 400 && cause.status < 500 && ![408, 429].includes(cause.status); + if (terminal) setPending(undefined); + setError(`${cause instanceof Error ? cause.message : "Access change failed"} (operation ${change.id})`); + } finally { await refresh(); setBusy(false); } + }; + return { message, setMessage, error, setError, busy, pending, refresh, execute }; +} + +type AccessOperation = ReturnType; + +export function AccessOperationStatus({ operation }: { operation: AccessOperation }) { + const { error, message, pending, busy, execute } = operation; + return <> + {error &&

{error}

} + {message &&

{message}

} + {pending &&
+

Completion is not confirmed. Retry the identical change or contact an operator with operation ID {pending.id} before making another change.

+ +
} + ; +} + +export function PendingAccessChange({ operationId }: { operationId?: string }) { + return + Change pending + Contact an operator. {operationId ? <>Operation ID: {operationId} : "Operation ID unavailable."} + ; +} diff --git a/apps/portal/src/components/AccessShell.stories.tsx b/apps/portal/src/components/AccessShell.stories.tsx new file mode 100644 index 000000000..af2556fbf --- /dev/null +++ b/apps/portal/src/components/AccessShell.stories.tsx @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent } from "storybook/test"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; +import { ProjectProvider } from "@/contexts/ProjectContext"; +import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; +import { AccessShell, NoAccessView } from "./AccessShell"; + +const meta = { + component: NoAccessView, + tags: ["ai-generated", "needs-work"], + args: { userId: "11111111-2222-4333-8444-555555555555", onLogout: () => {} }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const EnrolledWithoutMembership: Story = { + render: args => { + const [signedOut, setSignedOut] = useState(false); + return signedOut ?

Signed out

: setSignedOut(true)} />; + }, + play: async ({ canvas }) => { + await expect(canvas.getByRole("heading", { name: "No project access" })).toBeVisible(); + await expect(canvas.getByText(meta.args.userId)).toBeVisible(); + await expect(canvas.queryByRole("link")).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Sign out" })); + await expect(canvas.getByText("Signed out")).toBeVisible(); + }, +}; + +export const GlobalSecretsWithoutProject: Story = { + beforeEach: () => localStorage.removeItem(PROJECT_STORAGE_KEY), + render: () => + } /> +

Register global key

} /> +
, + play: async ({ canvas }) => { + await expect(await canvas.findByRole("heading", { name: "Register global key" })).toBeVisible(); + await expect(canvas.queryByText("Your project role permits reading only.")).not.toBeInTheDocument(); + }, +}; + +export const ProjectContributorCannotManageSecrets: Story = { + render: () => + } /> +

Register global account

} /> +
, + play: async ({ canvas }) => { + await expect(await canvas.findByText("Platform administration is not permitted.")).toBeVisible(); + await expect(canvas.queryByRole("heading", { name: "Register global account" })).not.toBeInTheDocument(); + }, +}; + +export const GlobalAgentsWithoutProject: Story = { + beforeEach: () => localStorage.removeItem(PROJECT_STORAGE_KEY), + render: () => + } /> +

Agent administration

} /> +
, + play: async ({ canvas }) => { + await expect(await canvas.findByRole("heading", { name: "Agent administration" })).toBeVisible(); + }, +}; + +export const ProjectMemberCannotOpenModels: Story = { + render: () => + } /> +

Model administration

} /> +
, + play: async ({ canvas }) => { + await expect(await canvas.findByText("Platform administration is not permitted.")).toBeVisible(); + await expect(canvas.queryByRole("heading", { name: "Model administration" })).not.toBeInTheDocument(); + }, +}; + +export const PlatformRoleRemoved: Story = { + render: () => { + const [admin, setAdmin] = useState(true); + return + } /> + + +

Model administration

+ } /> +
; + }, + play: async ({ canvas }) => { + await expect(await canvas.findByRole("heading", { name: "Model administration" })).toBeVisible(); + await userEvent.click(canvas.getByRole("button", { name: "Remove platform role" })); + await expect(canvas.getByText("Platform administration is not permitted.")).toBeVisible(); + await expect(canvas.queryByRole("heading", { name: "Model administration" })).not.toBeInTheDocument(); + }, +}; diff --git a/apps/portal/src/components/AccessShell.test.tsx b/apps/portal/src/components/AccessShell.test.tsx new file mode 100644 index 000000000..7976ec47f --- /dev/null +++ b/apps/portal/src/components/AccessShell.test.tsx @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, it } from "vitest"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { anonymousAuth, signedInAuth } from "@/contexts/authFixtures"; +import { ProjectProvider } from "@/contexts/ProjectContext"; +import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; +import { projectPermissions } from "shared/rbac"; +import type { ReactNode } from "react"; +import { vi } from "vitest"; +import { AccessShell } from "./AccessShell"; + +afterEach(() => { cleanup(); localStorage.removeItem(PROJECT_STORAGE_KEY); }); + +function tree(auth: AuthContextValue, path = "/runs", children: ReactNode =

Project content

) { + return + {children} + ; +} +const noMembership: AuthContextValue = { + ...signedInAuth, user: signedInAuth.user && { ...signedInAuth.user, + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [] } }, +}; + +it("renders anonymous content without manufacturing capabilities or clearing a selected project", () => { + localStorage.setItem(PROJECT_STORAGE_KEY, "anonymous-project"); + render(tree(anonymousAuth)); + expect(screen.getByText("Project content")).toBeTruthy(); + expect(localStorage.getItem(PROJECT_STORAGE_KEY)).toBe("anonymous-project"); + expect(screen.queryByText("Access capabilities are unavailable.")).toBeNull(); + expect(screen.queryByRole("link", { name: "Access administration" })).toBeNull(); +}); +it.each(["/access", "/ACCESS", "/acc%65ss"])("does not expose %s administration when auth is disabled", path => { + render(tree(anonymousAuth, path)); + expect(screen.getByText("Access administration requires authentication to be enabled.")).toBeTruthy(); + expect(screen.queryByText("Project content")).toBeNull(); +}); + +it("shows only the enrolled account when no memberships exist", () => { + render(tree(noMembership)); + expect(screen.getByRole("heading", { name: "No project access" })).toBeTruthy(); + expect(screen.queryByText("Project content")).toBeNull(); + expect(screen.queryByRole("link")).toBeNull(); +}); +it("clears a selected project after membership removal", async () => { + localStorage.setItem(PROJECT_STORAGE_KEY, "demo-project"); + const view = render(tree(signedInAuth)); + expect(screen.getByText("Project content")).toBeTruthy(); + view.rerender(tree(noMembership)); + await waitFor(() => expect(localStorage.getItem(PROJECT_STORAGE_KEY)).toBeNull()); + expect(screen.queryByText("Project content")).toBeNull(); +}); +it("does not allow an ordinary member to open platform administration", () => { + render(tree(signedInAuth, "/access")); + expect(screen.getByText("Platform administration is not permitted.")).toBeTruthy(); + expect(screen.queryByText("Project content")).toBeNull(); +}); +it("allows platform control-plane onboarding without implicit membership", () => { + const admin = { ...noMembership, user: noMembership.user && { ...noMembership.user, + capabilities: { platformAdmin: true, reportsEnabled: false as const, memberships: [] } } }; + render(tree(admin, "/access")); + expect(screen.getByRole("link", { name: "Access administration" })).toBeTruthy(); + expect(screen.getByText("Project content")).toBeTruthy(); +}); +it.each(["/access", "/ACCESS", "/acc%65ss"])("allows project admins only their exact project membership deep link at %s", path => { + const auth: AuthContextValue = { ...signedInAuth, user: { ...signedInAuth.user!, + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [ + { projectId: "project-a", role: "admin", permissions: projectPermissions("admin"), revision: 1 }, + ] } } }; + for (const suffix of ["", "?projectId=project-b", "?projectId="]) { + const view = render(tree(auth, `${path}${suffix}`)); + expect(screen.queryByText("Project content")).toBeNull(); + view.unmount(); + } + render(tree(auth, `${path}?projectId=project-a`)); + expect(screen.getByText("Project content")).toBeTruthy(); + expect(screen.queryByRole("link", { name: "Access administration" })).toBeNull(); +}); +it.each(["reader", "contributor"] as const)("denies membership deep links to a %s", role => { + const auth: AuthContextValue = { ...signedInAuth, user: { ...signedInAuth.user!, + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [ + { projectId: "project-a", role, permissions: projectPermissions(role), revision: 1 }, + ] } } }; + render(tree(auth, "/access?projectId=project-a")); + expect(screen.queryByText("Project content")).toBeNull(); +}); + +const secretsPaths = [ + "/secrets", "/secrets/keys", "/secrets/keys/new", "/secrets/keys/example", + "/secrets/keys/example/preview", "/secrets/accounts", "/secrets/accounts/new", "/secrets/accounts/example", +]; +const platformPaths = [ + ...secretsPaths, "/agents", "/agents/example", "/models", "/models/example", "/admin", "/admin/settings", + "/AGENTS", "/ag%65nts", "/agents/EXAMPLE", "/MODELS/example", "/mod%65ls/example", + "/SECRETS/keys", "/secr%65ts/keys/new", "/ADMIN", "/adm%69n/settings", +]; +const platformAdmin: AuthContextValue = { + ...noMembership, user: noMembership.user && { ...noMembership.user, role: "admin", + capabilities: { platformAdmin: true, reportsEnabled: false, memberships: [] } }, +}; + +it.each(platformPaths)("allows a platform admin without any project to open %s", path => { + render(tree(platformAdmin, path)); + expect(screen.getByText("Project content")).toBeTruthy(); + expect(screen.queryByText("Your project role permits reading only.")).toBeNull(); +}); + +it.each(["reader", "contributor", "admin"] as const)("does not grant platform management access to a project %s", role => { + const member: AuthContextValue = { ...signedInAuth, user: signedInAuth.user && { ...signedInAuth.user, + capabilities: { ...signedInAuth.user.capabilities, memberships: signedInAuth.user.capabilities.memberships.map(m => ({ ...m, role, permissions: projectPermissions(role) })) } } }; + const ProtectedPage = vi.fn(() =>

Protected page queried

); + for (const path of platformPaths) { + const view = render(tree(member, path, )); + expect(screen.getByText("Platform administration is not permitted.")).toBeTruthy(); + expect(ProtectedPage).not.toHaveBeenCalled(); + view.unmount(); + } +}); + +it.each(platformPaths)("keeps %s global when an admin selects a read-only project", path => { + localStorage.setItem(PROJECT_STORAGE_KEY, "demo-project"); + const admin: AuthContextValue = { ...platformAdmin, user: platformAdmin.user && { ...platformAdmin.user, + capabilities: { ...platformAdmin.user.capabilities, memberships: [ + { projectId: "demo-project", role: "reader", permissions: ["content:read"], revision: 1 }, + ] } } }; + render(tree(admin, path)); + expect(screen.getByText("Project content")).toBeTruthy(); +}); + +it.each(platformPaths)("preserves explicit auth-disabled access to %s", path => { + render(tree(anonymousAuth, path)); + expect(screen.getByText("Project content")).toBeTruthy(); +}); + +it.each(["/agents", "/models", "/secrets/keys"])("blocks %s while capabilities are resolving and after admin removal", path => { + const view = render(tree(platformAdmin, path)); + expect(screen.getByText("Project content")).toBeTruthy(); + view.rerender(tree({ ...platformAdmin, isReady: false, status: "resolving" }, path)); + expect(screen.queryByText("Project content")).toBeNull(); + expect(screen.getByText("Access capabilities are unavailable.")).toBeTruthy(); + view.rerender(tree(signedInAuth, path)); + expect(screen.queryByText("Project content")).toBeNull(); + expect(screen.getByText("Platform administration is not permitted.")).toBeTruthy(); +}); + +it("still blocks project creation forms without a project content grant", () => { + render(tree(platformAdmin, "/runs/new")); + expect(screen.getByText("Your project role permits reading only.")).toBeTruthy(); + expect(screen.queryByText("Project content")).toBeNull(); +}); diff --git a/apps/portal/src/components/AccessShell.tsx b/apps/portal/src/components/AccessShell.tsx new file mode 100644 index 000000000..e0f0d5041 --- /dev/null +++ b/apps/portal/src/components/AccessShell.tsx @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect, type ReactNode } from "react"; +import { Link, matchRoutes, useLocation } from "react-router-dom"; +import { useAuth } from "@/contexts/AuthContext"; +import { useProjectContext } from "@/contexts/ProjectContext"; +import { Button } from "@/components/ui/button"; + +const platformRoutes = ["/agents/*", "/models/*", "/secrets/*", "/admin/*", "/access"].map(path => ({ path })); + +export function NoAccessView({ userId, onLogout }: { userId: string; onLogout: () => void }) { + return
+

No project access

+

Your account is enrolled. Ask a project or platform administrator to assign a project membership.

+

Your Scope user ID: {userId}

+ +
; +} + +export function AccessShell({ children }: { children: ReactNode }) { + const { user, logout, authEnabled, isReady } = useAuth(); + const { selectedProjectId, setSelectedProjectId } = useProjectContext(); + const location = useLocation(); + const platformMatch = matchRoutes(platformRoutes, location)?.[0]; + const platformRoute = Boolean(platformMatch); + const capabilities = user?.capabilities; + const accessRoute = platformMatch?.route.path === "/access"; + const accessProjectId = new URLSearchParams(location.search).get("projectId"); + const projectMembershipAdmin = Boolean(accessRoute && accessProjectId && capabilities?.memberships.some( + m => m.projectId === accessProjectId && m.role === "admin" && m.permissions.includes("members:write"), + )); + const member = capabilities?.memberships.some(m => m.projectId === selectedProjectId); + useEffect(() => { + if (authEnabled && selectedProjectId && !member) setSelectedProjectId(undefined); + }, [authEnabled, selectedProjectId, member, setSelectedProjectId]); + if (!authEnabled) { + return platformMatch?.route.path === "/access" + ?
Access administration requires authentication to be enabled.
+ : <>{children}; + } + if (!isReady || !capabilities) return
Access capabilities are unavailable.
; + if (!capabilities.platformAdmin && !capabilities.memberships.length) { + return { void logout(); }} />; + } + if (platformRoute && !capabilities.platformAdmin && !projectMembershipAdmin) return
Platform administration is not permitted.
; + if (!platformRoute && location.pathname.endsWith("/new") && !capabilities.memberships.some(m => m.projectId === selectedProjectId && m.permissions.includes("content:write"))) { + return
Your project role permits reading only.
; + } + return <> + {capabilities.platformAdmin &&
Access administration
} + {!platformRoute && selectedProjectId && !member ?
Project access has changed. Select an accessible project.
: children} + ; +} diff --git a/apps/portal/src/components/AccessUserDialog.tsx b/apps/portal/src/components/AccessUserDialog.tsx new file mode 100644 index 000000000..8aff6ccc8 --- /dev/null +++ b/apps/portal/src/components/AccessUserDialog.tsx @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Check, Search } from "lucide-react"; +import type { AccessUserSearchResult, AccessUserSummary, ProjectMembershipSummary, ProjectRole } from "shared/rbac"; +import { ProjectRoleSchema } from "shared/rbac"; +import { api } from "@/lib/api"; +import { useDebounce } from "@/hooks/useDebounce"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; + +export const roleLabels: Record = { reader: "Reader", contributor: "Contributor", admin: "Project admin" }; +const roleDescriptions: Record = { + reader: "View project content, results and stored reports. Cannot make changes.", + contributor: "Create, edit and delete project content, and submit runs. Cannot change project settings or members.", + admin: "Manage project content, settings and membership. Only platform admins can delete the project.", +}; +export const userName = (user: AccessUserSummary) => user.displayName || user.email || user.alias || user.id; + +export function Person({ user, fallback }: { user?: AccessUserSummary; fallback: string }) { + const name = user ? userName(user) : fallback; + const address = user?.email || user?.alias; + return
+ +
+

{name}

+ {address && address !== name &&

{address}

} +
+
; +} + +export function AccessUserDialog({ members = [], initialUser, initialRole, platformAdmin = false, searchProjectId, selfUserId, onClose, onSave }: { + members?: ProjectMembershipSummary[]; + initialUser?: AccessUserSummary; + initialRole?: ProjectRole; + platformAdmin?: boolean; + searchProjectId?: string; + selfUserId?: string; + onClose: () => void; + onSave: (user: AccessUserSummary, role: ProjectRole) => void; +}) { + const [search, setSearch] = useState(""); + const query = useDebounce(search.trim(), 250); + const [cursor, setCursor] = useState(); + const [selected, setSelected] = useState(initialUser); + const [role, setRole] = useState(initialRole ?? "reader"); + const [error, setError] = useState(""); + const scopedSearch = searchProjectId !== undefined && !platformAdmin; + const usersQuery = useQuery({ + queryKey: ["access-users", scopedSearch ? searchProjectId : "platform", query, cursor], + queryFn: async ({ signal }): Promise => scopedSearch + ? api.searchProjectMembers(searchProjectId, { q: query, cursor, signal }) + : api.searchAccessUsers({ q: query, cursor, signal }), + enabled: !initialUser, + retry: false, + }); + const searching = search.trim() !== query || usersQuery.isFetching; + return { if (!open) onClose(); }}> + + + {platformAdmin ? "Add platform admin" : initialUser ? "Change role" : "Add people"} + {platformAdmin + ? "Choose an enabled user who has signed in to Scope. No invitation is sent." + : initialUser + ? `Update ${userName(initialUser)}'s access to this project.` + : "Find an existing Scope user, then choose the access to grant. No invitation is sent."} + +
+ {!initialUser && <> + +
+
+ {usersQuery.isError ?
+ User search unavailable: {usersQuery.error.message}{" "} + +
: searching ?

Searching users...

: <> +
    + {usersQuery.data?.items.map(user => { + const membership = members.find(member => member.userId === user.id); + const existing = !platformAdmin && Boolean(membership && !membership.revokedAt); + const unavailableRole = platformAdmin && user.role !== "user"; + const unavailable = user.disabled || existing || Boolean(membership?.pending) || unavailableRole; + return
  • + +
  • ; + })} + {!usersQuery.data?.items.length &&
  • No users found.
  • } +
+
+ {cursor && } + {usersQuery.data?.nextCursor && } +
+ } + } + {selected &&
} + {!platformAdmin &&
+ + +

{roleDescriptions[role]}

+ {initialUser?.id === selfUserId && initialRole === "admin" && role !== "admin" && +

You will lose membership-management access. A platform administrator can restore it; the project does not require another project admin.

} + {error &&

{error}

} +
} +
+ + + + +
+
; +} diff --git a/apps/portal/src/components/AgentBadge.stories.tsx b/apps/portal/src/components/AgentBadge.stories.tsx index a52023b70..eeba92182 100644 --- a/apps/portal/src/components/AgentBadge.stories.tsx +++ b/apps/portal/src/components/AgentBadge.stories.tsx @@ -2,7 +2,9 @@ // Licensed under the MIT License. import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect } from "storybook/test"; +import { expect, screen, userEvent } from "storybook/test"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; import { AgentBadge } from "./AgentBadge"; import type { CodingAgent } from "@/types"; @@ -18,6 +20,12 @@ const activeAgent: CodingAgent = { const meta = { component: AgentBadge, tags: ["ai-generated", "needs-work"], + decorators: [ + Story => , + ], args: { agentId: activeAgent._id, agent: activeAgent, @@ -58,6 +66,18 @@ export const SelectionControl: Story = { args: { triggerLink: false }, }; +export const ProjectMember: Story = { + render: args => , + play: async ({ canvas }) => { + const label = canvas.getByText(activeAgent.name); + await expect(label.closest("a")).toBeNull(); + await userEvent.hover(label); + await expect((await screen.findAllByText(activeAgent._id))[0]).toBeVisible(); + await expect(screen.queryByRole("link", { name: /View agent/ })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("link")).not.toBeInTheDocument(); + }, +}; + export const Unknown: Story = { args: { agentId: "missing-agent", diff --git a/apps/portal/src/components/AgentBadge.test.tsx b/apps/portal/src/components/AgentBadge.test.tsx index 9ada144fb..5a6dcee36 100644 --- a/apps/portal/src/components/AgentBadge.test.tsx +++ b/apps/portal/src/components/AgentBadge.test.tsx @@ -10,6 +10,8 @@ import { MemoryRouter } from "react-router-dom"; import type { ReactNode } from "react"; import type { CodingAgent } from "@/types"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { anonymousAuth, signedInAuth } from "@/contexts/authFixtures"; const { listAgents } = vi.hoisted(() => ({ listAgents: vi.fn< @@ -32,13 +34,17 @@ const activeAgent: CodingAgent = { createdAt: "2026-01-01T00:00:00.000Z", }; -function renderBadge(ui: ReactNode) { +const adminAuth: AuthContextValue = { ...signedInAuth, user: signedInAuth.user && { ...signedInAuth.user, + capabilities: { ...signedInAuth.user.capabilities, platformAdmin: true }, +} }; + +function renderBadge(ui: ReactNode, auth: AuthContextValue = adminAuth) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); return render( - {ui} + {ui} , ); } @@ -109,4 +115,30 @@ describe("AgentBadge", () => { expect(screen.getByRole("link", { name: activeAgent.name })).toBeTruthy(); expect(listAgents).not.toHaveBeenCalled(); }); + + it("keeps catalog names and hover metadata for members without management links", async () => { + listAgents.mockResolvedValue([activeAgent]); + renderBadge(, signedInAuth); + const label = await screen.findByText(activeAgent.name); + expect(label.closest("a")).toBeNull(); + await userEvent.hover(label); + expect((await screen.findAllByText(activeAgent._id)).length).toBeGreaterThan(0); + expect(screen.getAllByText("example-version").length).toBeGreaterThan(0); + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.queryByText("View agent")).toBeNull(); + expect(listAgents).toHaveBeenCalledWith({ includeDeleted: true }); + }); + + it("does not query or link the catalog while auth capabilities are resolving", () => { + renderBadge(, { + ...adminAuth, isReady: false, status: "resolving", + }); + expect(listAgents).not.toHaveBeenCalled(); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it("keeps links in explicit auth-disabled mode", () => { + renderBadge(, anonymousAuth); + expect(screen.getByRole("link", { name: activeAgent.name })).toBeTruthy(); + }); }); diff --git a/apps/portal/src/components/AgentBadge.tsx b/apps/portal/src/components/AgentBadge.tsx index f9609eec3..6716dd308 100644 --- a/apps/portal/src/components/AgentBadge.tsx +++ b/apps/portal/src/components/AgentBadge.tsx @@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { useAuth } from "@/contexts/AuthContext"; import type { CodingAgent } from "@/types"; const AGENT_CATALOG_QUERY_KEY = ["agents", "include-deleted"] as const; @@ -66,10 +67,12 @@ export function AgentBadge({ className, children, }: AgentBadgeProps) { - const { agentById, isLoading } = useAgentCatalog(!preloadedAgent); + const { user, authEnabled, isReady } = useAuth(); + const { agentById, isLoading } = useAgentCatalog(!preloadedAgent && isReady); const agent = preloadedAgent ?? (agentId ? agentById.get(agentId) : undefined); const label = agent?.name ?? (isLoading ? "Loading agent…" : "Unknown agent"); - const canNavigate = !!agentId && !!agent; + const canNavigate = !!agentId && !!agent && + (!authEnabled || (isReady && Boolean(user?.capabilities?.platformAdmin))); const content = children ?? ( variant === "badge" ? ( diff --git a/apps/portal/src/components/HomeRoute.test.tsx b/apps/portal/src/components/HomeRoute.test.tsx index e68d37d43..b68942478 100644 --- a/apps/portal/src/components/HomeRoute.test.tsx +++ b/apps/portal/src/components/HomeRoute.test.tsx @@ -10,6 +10,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ProjectProvider } from "@/contexts/ProjectContext"; import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; import { HomeRoute } from "./HomeRoute"; +import { AuthContext } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; // The picker lists projects via `api.listProjects`; return one so we can also // exercise the pick -> forward path. Keep the rest of the facade real. @@ -46,14 +48,14 @@ function renderHome(initial = "/") { }); return render( - + } /> Statistics page} /> - + , ); } diff --git a/apps/portal/src/components/Layout.stories.tsx b/apps/portal/src/components/Layout.stories.tsx index fe59b9f18..ba1fe4c67 100644 --- a/apps/portal/src/components/Layout.stories.tsx +++ b/apps/portal/src/components/Layout.stories.tsx @@ -19,7 +19,10 @@ const meta = { tags: ["ai-generated", "needs-work"], decorators: [ (Story) => ( - + ), @@ -72,6 +75,28 @@ export const NoProjectSelected: Story = { }, }; +export const MemberNavigation: Story = { + render: () => , + play: async ({ canvas }) => { + for (const name of ["Agents", "Models", "Secrets", "Admin"]) { + await expect(canvas.queryByRole("link", { name })).not.toBeInTheDocument(); + } + }, +}; + +export const AdminWithoutMembership: Story = { + beforeEach: () => localStorage.removeItem(PROJECT_STORAGE_KEY), + render: () => , + play: async ({ canvas }) => { + for (const name of ["Agents", "Models", "Secrets", "Admin"]) { + await expect(canvas.getByRole("link", { name })).toBeVisible(); + } + }, +}; + const previewKeys: KeyDocument[] = Array.from({ length: 7 }, (_, index) => ({ _id: `00000000000000000000000${index}`, type: "github-pat-fine-grained", diff --git a/apps/portal/src/components/Layout.test.tsx b/apps/portal/src/components/Layout.test.tsx index 755a9cdf9..b92a66cbd 100644 --- a/apps/portal/src/components/Layout.test.tsx +++ b/apps/portal/src/components/Layout.test.tsx @@ -7,8 +7,9 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { Link, MemoryRouter, Outlet, Route, Routes } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@/contexts/ThemeContext"; -import { AuthContext } from "@/contexts/AuthContext"; -import { signedInAuth } from "@/contexts/authFixtures"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { anonymousAuth, signedInAuth } from "@/contexts/authFixtures"; +import { projectPermissions } from "shared/rbac"; import { ProjectProvider } from "@/contexts/ProjectContext"; import { PROJECT_STORAGE_KEY } from "@/lib/project-scope"; import { Layout } from "./Layout"; @@ -28,9 +29,17 @@ beforeAll(() => { ); }); +const adminAuth: AuthContextValue = { + ...signedInAuth, + user: signedInAuth.user && { ...signedInAuth.user, capabilities: { + ...signedInAuth.user.capabilities, platformAdmin: true, + memberships: signedInAuth.user.capabilities.memberships.map(membership => ({ ...membership, projectId: "proj-1" })), + } }, +}; + function renderLayout( path = "/runs", - { projectId = "proj-1" }: { projectId?: string | null } = {}, + { projectId = "proj-1", auth = adminAuth }: { projectId?: string | null; auth?: AuthContextValue } = {}, ) { // Layout gates project-scoped nav on a selected project, so seed one by // default; pass { projectId: null } to exercise the no-project state. @@ -42,7 +51,7 @@ function renderLayout( }); return render( - + @@ -218,4 +227,55 @@ describe("Layout", () => { expect(screen.getByText("Home page")).toBeTruthy(); expect(localStorage.getItem(PROJECT_STORAGE_KEY)).toBe("proj-1"); }); + + it.each(["reader", "contributor", "admin"] as const)("hides platform navigation for project %s in desktop and mobile", role => { + localStorage.setItem("scope:layout:sidebar-expanded", "1"); + const auth: AuthContextValue = { ...signedInAuth, user: signedInAuth.user && { ...signedInAuth.user, + capabilities: { ...signedInAuth.user.capabilities, memberships: [{ + projectId: "proj-1", role, permissions: projectPermissions(role), revision: 1, + }] }, + } }; + renderLayout("/runs", { auth }); + const sidebar = screen.getByLabelText("Primary navigation"); + expect(within(sidebar).queryByText("Platform")).toBeNull(); + for (const name of ["Agents", "Models", "Secrets", "Admin"]) { + expect(within(sidebar).queryByRole("link", { name })).toBeNull(); + } + expect(within(sidebar).getByRole("link", { name: "Runs" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + const mobile = screen.getByRole("dialog"); + expect(within(mobile).queryByText("Platform")).toBeNull(); + for (const name of ["Agents", "Models", "Secrets", "Admin"]) { + expect(within(mobile).queryByRole("link", { name })).toBeNull(); + } + expect(within(mobile).getByRole("link", { name: "Runs" })).toBeTruthy(); + }); + + it("shows global platform navigation for an admin with no memberships or selection", () => { + const auth: AuthContextValue = { ...adminAuth, user: adminAuth.user && { ...adminAuth.user, + capabilities: { ...adminAuth.user.capabilities, memberships: [] }, + } }; + renderLayout("/runs", { auth, projectId: null }); + for (const name of ["Agents", "Models", "Secrets", "Admin"]) { + expect(screen.getByRole("link", { name })).toBeTruthy(); + } + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + expect(within(screen.getByRole("dialog")).getByRole("link", { name: "Admin" })).toBeTruthy(); + }); + + it("hides platform links while capabilities are resolving", () => { + renderLayout("/runs", { auth: { ...adminAuth, isReady: false, status: "resolving" } }); + for (const name of ["Agents", "Models", "Secrets", "Admin"]) { + expect(screen.queryByRole("link", { name })).toBeNull(); + } + fireEvent.click(screen.getByRole("button", { name: "Toggle navigation" })); + expect(within(screen.getByRole("dialog")).queryByRole("link", { name: "Admin" })).toBeNull(); + }); + + it("preserves platform navigation with authentication explicitly disabled", () => { + renderLayout("/runs", { auth: anonymousAuth }); + for (const name of ["Agents", "Models", "Secrets", "Admin"]) { + expect(screen.getByRole("link", { name })).toBeTruthy(); + } + }); }); diff --git a/apps/portal/src/components/Layout.tsx b/apps/portal/src/components/Layout.tsx index 2a08d36cd..627e2095b 100644 --- a/apps/portal/src/components/Layout.tsx +++ b/apps/portal/src/components/Layout.tsx @@ -50,6 +50,7 @@ import { UserMenu } from "./UserMenu"; import { ProjectSwitcher } from "./ProjectSwitcher"; import { useFeatureFlags } from "@/contexts/FeatureFlagContext"; import { useProjectContext } from "@/contexts/ProjectContext"; +import { useAuth } from "@/contexts/AuthContext"; interface NavItem { to: string; @@ -83,7 +84,7 @@ interface NavGroup { * Activity, Library and Integrations are `scoped`: their pages live behind a * ProjectGate, so the sidebar hides them until a project is selected. Platform * is global - it lives at the tenant level and is shared by every project, so - * it stays visible without a selection and is pinned last. + * it stays visible to platform admins without a selection and is pinned last. * * The pinned Projects link (global) and the dev-only MDP view (scoped) are * rendered outside these groups; New Run and MDP are gated on the selection too. @@ -249,7 +250,11 @@ function SidebarIconLink({ export function Layout() { const location = useLocation(); const { isFeatureEnabled } = useFeatureFlags(); - const { hasProject } = useProjectContext(); + const { hasProject, selectedProjectId } = useProjectContext(); + const { user, authEnabled, isReady } = useAuth(); + const capabilities = user?.capabilities; + const canManagePlatform = !authEnabled || (isReady && Boolean(capabilities?.platformAdmin)); + const canWrite = !authEnabled || capabilities?.memberships.some(m => m.projectId === selectedProjectId && m.permissions.includes("content:write")); const [mobileOpen, setMobileOpen] = useState(false); const [sidebarExpanded, setSidebarExpanded] = useState(() => { try { @@ -274,12 +279,10 @@ export function Layout() { () => navGroups .map((g) => ({ ...g, items: filterByFeature(g.items) })) - // Scoped groups only appear once a project is in use; global groups - // (e.g. Platform) always show. Empty groups are dropped so no stray - // separator/label is left behind. - .filter((g) => (hasProject || !g.scoped) && g.items.length > 0), + .filter((g) => (hasProject || !g.scoped) && + (g.id !== "platform" || canManagePlatform) && g.items.length > 0), // eslint-disable-next-line react-hooks/exhaustive-deps - [isFeatureEnabled, hasProject], + [isFeatureEnabled, hasProject, canManagePlatform], ); // MDP points at /criteria/mdp (project-scoped), so hide the Dev section until // a project is selected. @@ -374,7 +377,7 @@ export function Layout() { {/* New Run — emphasized primary CTA. Scoped (submitting a run needs a project), so it's hidden along with its divider until one is selected; Projects then becomes the first pinned item. */} - {hasProject && ( + {hasProject && canWrite && ( <> - + {canManagePlatform && ( + + )} @@ -568,7 +573,7 @@ export function Layout() { API Documentation - setMobileOpen(false)} className={cn( @@ -580,7 +585,7 @@ export function Layout() { > Admin - + } diff --git a/apps/portal/src/components/PlatformAdministrators.tsx b/apps/portal/src/components/PlatformAdministrators.tsx new file mode 100644 index 000000000..13eb179c2 --- /dev/null +++ b/apps/portal/src/components/PlatformAdministrators.tsx @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2, Plus, ShieldCheck } from "lucide-react"; +import type { AccessUserSummary } from "shared/rbac"; +import { api } from "@/lib/api"; +import { AccessUserDialog, Person, userName } from "@/components/AccessUserDialog"; +import { AccessOperationStatus, PendingAccessChange, useAccessOperation } from "@/components/AccessOperation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +export function PlatformAdministrators() { + const [adminEditor, setAdminEditor] = useState(false); + const [promotion, setPromotion] = useState(); + const [adminCursor, setAdminCursor] = useState(); + const operation = useAccessOperation(() => { setAdminEditor(false); setPromotion(undefined); }); + const { busy, pending, execute } = operation; + const locked = busy || Boolean(pending); + const adminsQuery = useQuery({ + queryKey: ["access-platform-admins", adminCursor], + queryFn: ({ signal }) => api.listPlatformAdmins({ cursor: adminCursor, signal }), + retry: false, + }); + return
+
+
+

+ +
+

Platform admins manage users, memberships and project lifecycle across the platform. + Project content still requires membership. Removal, demotion and account recovery remain operator-only.

+ {Boolean(operation.error || operation.message || pending) &&
} + {adminsQuery.isError ?
+ Platform administrator list unavailable: {adminsQuery.error.message}{" "} + +
: adminsQuery.isPending ?

+

: <> +
    + {adminsQuery.data.items.map(user =>
  • + +
    + {user.disabled && Account disabled} + {user.pending && } + {user.disabled || user.pending ? "Platform admin (inactive)" : "Platform admin"} +
    +
  • )} + {!adminsQuery.data.items.length &&
  • No platform administrators on this page.
  • } +
+ {(adminCursor || adminsQuery.data.nextCursor) &&
+ {adminCursor && } + {adminsQuery.data.nextCursor && } +
} + } +
+ {adminEditor && setAdminEditor(false)} + onSave={user => { setAdminEditor(false); setPromotion(user); }} />} + { if (!open) setPromotion(undefined); }}> + + + Grant platform administration? + + {promotion ? userName(promotion) : "This user"} will be able to manage users, promote other platform admins, manage memberships and create or delete projects across the entire platform. + This does not grant project content access. Demotion requires an operator. + + + + Cancel + { + if (!promotion || locked) return; + setAdminCursor(undefined); + void execute({ + id: crypto.randomUUID(), path: `/admin/users/${encodeURIComponent(promotion.id)}/promote`, method: "POST", + body: { revision: promotion.revision }, description: `${userName(promotion)}'s platform-admin promotion`, + }); + }}>Grant platform admin + + + +
; +} diff --git a/apps/portal/src/components/ProjectCreateForm.tsx b/apps/portal/src/components/ProjectCreateForm.tsx index f6ab17174..d322ca1d9 100644 --- a/apps/portal/src/components/ProjectCreateForm.tsx +++ b/apps/portal/src/components/ProjectCreateForm.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useState } from "react"; +import { useContext, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Plus } from "lucide-react"; import { toast } from "sonner"; @@ -12,6 +12,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; import type { Project } from "@/types"; +import { AuthContext } from "@/contexts/AuthContext"; const NAME_MAX = 128; const DESCRIPTION_MAX = 512; @@ -43,34 +44,28 @@ export function ProjectCreateForm({ className, }: ProjectCreateFormProps) { const queryClient = useQueryClient(); + const authEnabled = useContext(AuthContext)?.authEnabled !== false; const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [operationId, setOperationId] = useState(() => crypto.randomUUID()); const createMutation = useMutation({ mutationFn: () => api.createProject({ name: name.trim(), ...(description.trim() ? { description: description.trim() } : {}), - }), + }, operationId), onSuccess: (project) => { - // Merge the new project into the shared ["projects"] cache before handing - // off to `onCreated`. Callers that select it right away (the switcher - // dialog, the first-run gate) go through `useSelectProject`, which - // deliberately *preserves* the projects list on switch — so without this - // upsert the freshly-selected id isn't in the list yet and the header - // switcher renders "Unknown project". Invalidate afterwards to reconcile - // with the server (ordering, server-only fields, etc.). - const newId = project.id ?? project._id; - queryClient.setQueryData(["projects"], (old) => { - if (!old) return [project]; - return old.some((p) => (p.id ?? p._id) === newId) ? old : [...old, project]; - }); + setOperationId(crypto.randomUUID()); + // Refresh canonical inventories instead of treating the mutation response + // as a membership grant (an idempotent retry may follow a revocation). void queryClient.invalidateQueries({ queryKey: ["projects"] }); - toast.success(`Project "${project.name}" created`); + void queryClient.invalidateQueries({ queryKey: ["access-projects"] }); + toast.success(`Project "${project.name}" created.${authEnabled ? " Creator access refreshes automatically." : ""}`); onCreated(project); }, onError: (error) => { - toast.error(error instanceof Error ? error.message : "Failed to create project"); + toast.error(`${error instanceof Error ? error.message : "Failed to create project"} (operation ${operationId})`); }, }); @@ -90,6 +85,7 @@ export function ProjectCreateForm({ submit(); }} > + {authEnabled &&

New projects grant their creator an audited project-admin membership.

}
diff --git a/apps/portal/src/components/ProjectGate.tsx b/apps/portal/src/components/ProjectGate.tsx index ad743a3ab..018665c43 100644 --- a/apps/portal/src/components/ProjectGate.tsx +++ b/apps/portal/src/components/ProjectGate.tsx @@ -19,6 +19,8 @@ import { useProjectContext } from "@/contexts/ProjectContext"; import { useSelectProject } from "@/hooks/useSelectProject"; import { api } from "@/lib/api"; import type { Project } from "@/types"; +import { useAuth } from "@/contexts/AuthContext"; +import { useQueryClient } from "@tanstack/react-query"; /** Stable id accessor — the API mirrors `_id` onto `id`, but fall back defensively. */ function projectId(project: Project): string { @@ -117,6 +119,8 @@ export function ProjectFirstRunView({ * gated page can fire its scoped requests. */ export function ProjectFirstRunScreen() { + const { user, authEnabled } = useAuth(); + const queryClient = useQueryClient(); const selectProject = useSelectProject(); const { data: projects = [], isLoading } = useQuery({ queryKey: ["projects"], @@ -132,11 +136,11 @@ export function ProjectFirstRunScreen() { isLoading={isLoading} onSelect={selectProject} createSlot={ - selectProject(projectId(project))} - /> + submitLabel="Create project" + onCreated={() => { void queryClient.invalidateQueries({ queryKey: ["projects"] }); }} + /> :

Ask a platform administrator for project access.

} /> ); diff --git a/apps/portal/src/components/ProjectSwitcher.tsx b/apps/portal/src/components/ProjectSwitcher.tsx index 31636d476..26740643c 100644 --- a/apps/portal/src/components/ProjectSwitcher.tsx +++ b/apps/portal/src/components/ProjectSwitcher.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { Check, ChevronsUpDown, FolderKanban, Plus, Settings2 } from "lucide-react"; import { ProjectCreateForm } from "@/components/ProjectCreateForm"; @@ -130,6 +130,7 @@ export function ProjectSwitcherView({ * `/projects` management page. */ export function ProjectSwitcher({ className }: { className?: string }) { + const navigate = useNavigate(); const { selectedProjectId } = useProjectContext(); const selectProject = useSelectProject(); const [createOpen, setCreateOpen] = useState(false); @@ -177,10 +178,9 @@ export function ProjectSwitcher({ className }: { className?: string }) { setCreateOpen(false)} - onCreated={(project) => { + onCreated={() => { setCreateOpen(false); - // Select the new project and refresh scoped data + the list. - selectProject(projectId(project)); + navigate("/projects"); }} /> diff --git a/apps/portal/src/components/auth/AuthModeGate.stories.tsx b/apps/portal/src/components/auth/AuthModeGate.stories.tsx new file mode 100644 index 000000000..d3a26534f --- /dev/null +++ b/apps/portal/src/components/auth/AuthModeGate.stories.tsx @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { expect } from "storybook/test"; +import { AuthModeGate } from "./AuthModeGate"; + +const meta = { + component: AuthModeGate, + tags: ["ai-generated", "needs-work"], + args: { children:

Scope application

, expectedEnabled: false }, + parameters: { msw: { handlers: [ + http.get("/api/v1/version", () => HttpResponse.json({ authEnabled: false })), + ] } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const AnonymousDeployment: Story = { + play: async ({ canvas }) => { + await expect(await canvas.findByText("Scope application")).toBeVisible(); + await expect(canvas.queryByRole("alert")).not.toBeInTheDocument(); + }, +}; +export const MismatchedDeployment: Story = { + args: { expectedEnabled: true }, + play: async ({ canvas }) => { + await expect(await canvas.findByRole("alert")).toHaveTextContent("Authentication configuration unavailable or mismatched"); + await expect(canvas.queryByText("Scope application")).not.toBeInTheDocument(); + }, +}; +export const MissingMode: Story = { + parameters: { msw: { handlers: [ + http.get("/api/v1/version", () => HttpResponse.json({})), + ] } }, + play: async ({ canvas }) => { + await expect(await canvas.findByRole("alert")).toBeVisible(); + await expect(canvas.queryByText("Scope application")).not.toBeInTheDocument(); + }, +}; diff --git a/apps/portal/src/components/auth/AuthModeGate.test.tsx b/apps/portal/src/components/auth/AuthModeGate.test.tsx new file mode 100644 index 000000000..61fba93a7 --- /dev/null +++ b/apps/portal/src/components/auth/AuthModeGate.test.tsx @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { cleanup, render, screen, within } from "@testing-library/react"; +import { composeStories } from "@storybook/react-vite"; +import { setupServer } from "msw/node"; +import { http, HttpResponse, type RequestHandler } from "msw"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { setApiSessionSignal, setApiTokenProvider, resetApiClient } from "@/lib/api-client"; +import { AuthModeGate } from "./AuthModeGate"; +import * as stories from "./AuthModeGate.stories"; + +const server = setupServer(); +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => { cleanup(); resetApiClient(); server.resetHandlers(); }); +afterAll(() => server.close()); + +describe("Deployment mode bootstrap", () => { + for (const [name, Story] of Object.entries(composeStories(stories))) { + it(`${name} renders and play passes`, async () => { + const parameters: { msw?: { handlers?: RequestHandler[] } } = Story.parameters; + server.use(...(parameters.msw?.handlers ?? [])); + const { container } = render(); + if (!Story.play) throw new Error("Story must cover its state"); + await Story.play({ canvas: within(container), canvasElement: container }); + }); + } + + it("checks the public API mode without bearer acquisition or stale account cancellation", async () => { + const token = vi.fn(() => "must-not-be-sent"); + setApiTokenProvider(token); + setApiSessionSignal(AbortSignal.abort()); + server.use(http.get("/api/v1/version", ({ request }) => { + expect(request.headers.has("Authorization")).toBe(false); + return HttpResponse.json({ authEnabled: false }); + })); + render(Anonymous application); + await screen.findByText("Anonymous application"); + expect(token).not.toHaveBeenCalled(); + }); + + it("does not turn a failed API mode check into anonymous access", async () => { + server.use(http.get("/api/v1/version", () => HttpResponse.json({ error: "Not available" }, { status: 404 }))); + render(Anonymous application); + await screen.findByRole("alert"); + expect(screen.queryByText("Anonymous application")).toBeNull(); + }); +}); diff --git a/apps/portal/src/components/auth/AuthModeGate.tsx b/apps/portal/src/components/auth/AuthModeGate.tsx new file mode 100644 index 000000000..c19138cbc --- /dev/null +++ b/apps/portal/src/components/auth/AuthModeGate.tsx @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState, type ReactNode } from "react"; +import { QueryClient, useQuery } from "@tanstack/react-query"; +import { api, ApiError } from "@/lib/api"; +import { isAuthEnabled } from "@/lib/auth/msalInstance"; +import { Button } from "@/components/ui/button"; + +export function AuthModeGate({ children, expectedEnabled = isAuthEnabled }: { + children: ReactNode; + expectedEnabled?: boolean; +}) { + // Account changes clear application queries, not the deployment-mode check. + const [client] = useState(() => new QueryClient()); + const { data, error, isPending, refetch } = useQuery({ + queryKey: ["api-auth-mode"], + queryFn: ({ signal }) => api.getAuthMode({ + signal: AbortSignal.any([signal, AbortSignal.timeout(5_000)]), + }), + retry: (count, cause) => count < 1 && (cause instanceof TypeError || + cause instanceof DOMException && cause.name === "TimeoutError" || + cause instanceof ApiError && (cause.status === 429 || cause.status >= 500)), + retryDelay: 1_000, + refetchInterval: 10_000, + refetchIntervalInBackground: true, + gcTime: 0, + }, client); + + if (isPending) return
Checking authentication configuration...
; + if (error || data?.authEnabled !== expectedEnabled) return
+

Authentication configuration unavailable or mismatched

+

Set the same authentication mode on the API and Portal, then restart both services.

+ {error &&

{error.message}

} + +
; + return <>{children}; +} diff --git a/apps/portal/src/contexts/AuthContext.test.tsx b/apps/portal/src/contexts/AuthContext.test.tsx index 4427c911b..344f0ae92 100644 --- a/apps/portal/src/contexts/AuthContext.test.tsx +++ b/apps/portal/src/contexts/AuthContext.test.tsx @@ -11,7 +11,7 @@ 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 { api, type CurrentUserResponse } from "@/lib/api"; import { resetApiClient, setApiTokenProvider, setReauthHandler, type TokenProvider, type ReauthHandler, @@ -73,7 +73,8 @@ const scopeAlice = { role: "user", displayName: "Scope Alice", email: "scope-alice@example.test", -}; + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [] }, +} satisfies CurrentUserResponse; function deferred() { let resolve!: (value: T) => void; @@ -136,6 +137,7 @@ describe("Scope authentication handshake", () => { focusManager.setFocused(undefined); resetApiClient(); vi.unstubAllGlobals(); + vi.useRealTimers(); }); function freshLogin() { @@ -168,6 +170,46 @@ describe("Scope authentication handshake", () => { return fetchMock.mock.calls.map(([request]) => (request as Request).method); } + it("refreshes capabilities by GET only and clears cached project content on membership loss", async () => { + vi.useFakeTimers(); + freshLogin(); + const initial: CurrentUserResponse = { + ...scopeAlice, capabilities: { platformAdmin: false, reportsEnabled: false, + memberships: [{ projectId: "project-a", role: "contributor", revision: 1, + permissions: ["content:read", "content:write", "content:delete"] }] }, + }; + fetchMock.mockImplementation(async (req: Request) => response( + pathOf(req).startsWith("/api/v1/users/me") ? initial : [], + )); + await act(async () => { render(tree()); }); + expect(auth.status).toBe("ready"); + client.setQueryData(["private-project-data"], "sensitive fixture"); + fetchMock.mockImplementation(async (req: Request) => response( + pathOf(req).startsWith("/api/v1/users/me") ? scopeAlice : [], + )); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(auth.user?.capabilities.memberships).toEqual([]); + expect(client.getQueryData(["private-project-data"])).toBeUndefined(); + const identityRequests: Request[] = fetchMock.mock.calls.map(([req]) => req) + .filter(req => pathOf(req).startsWith("/api/v1/users/me")); + expect(identityRequests.map(req => req.method)).toEqual(["POST", "GET"]); + }); + + it("denies the session and clears cached content when capability authority is unavailable", async () => { + vi.useFakeTimers(); + await act(async () => { render(tree()); }); + expect(auth.status).toBe("ready"); + client.setQueryData(["private-project-data"], "sensitive fixture"); + fetchMock.mockImplementation(async (req: Request) => response( + { error: "Authority unavailable" }, pathOf(req).startsWith("/api/v1/users/me") ? 503 : 200, + )); + await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); + expect(auth.isReady).toBe(false); + expect(auth.user).toBeNull(); + expect(client.getQueryData(["private-project-data"])).toBeUndefined(); + expect(requestMethods()).not.toContain("POST"); + }); + it("sends exactly the enrollment POST first; flags, favicon and pages wait for its response", async () => { freshLogin(); const lookup = deferred(); @@ -193,8 +235,9 @@ describe("Scope authentication handshake", () => { await act(async () => lookup.resolve(response(scopeAlice))); await waitFor(() => expect(requests()).toEqual(expect.arrayContaining([ - "/api/v1/feature-flags", "/api/v1/version", "/api/v1/projects", + "/api/v1/version", "/api/v1/projects", ]))); + expect(requests()).not.toContain("/api/v1/feature-flags"); expect(auth.user).toMatchObject({ ...scopeAlice, name: "Scope Alice", username: alice.username, subject: alice.localAccountId, }); @@ -212,13 +255,14 @@ describe("Scope authentication handshake", () => { }); it("uses a plain lookup on cached-account reload and keeps IdP display fallbacks", async () => { - fetchMock.mockResolvedValueOnce(response({ id: scopeAlice.id, role: "admin" })); + fetchMock.mockResolvedValueOnce(response({ id: scopeAlice.id, role: "admin", capabilities: { ...scopeAlice.capabilities, platformAdmin: true } })); 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, + capabilities: { ...scopeAlice.capabilities, platformAdmin: true }, username: alice.username, subject: alice.localAccountId, }); expect(msal.consume).not.toHaveBeenCalled(); @@ -245,11 +289,13 @@ describe("Scope authentication handshake", () => { msal.account = null; msal.enabled = false; render(tree()); - await waitFor(() => expect(requests()).toContain("/api/v1/feature-flags")); + await waitFor(() => expect(requests()).toContain("/api/v1/projects")); + 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.authEnabled).toBe(false); expect(auth.isReady).toBe(true); }); @@ -385,7 +431,7 @@ describe("Scope authentication handshake", () => { 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 act(async () => second.resolve(response({ id: bobId, role: "user", capabilities: scopeAlice.capabilities }))); await waitFor(() => expect(auth.user?.id).toBe(bobId)); expect(auth.user?.subject).toBe("bob-subject"); expect(auth.user?.name).toBe("Bob"); @@ -438,7 +484,7 @@ describe("Scope authentication handshake", () => { expect(auth.user).toBeNull(); const bobId = "66666666-7777-4888-9999-aaaaaaaaaaaa"; - await act(async () => nextLookup.resolve(response({ id: bobId }))); + await act(async () => nextLookup.resolve(response({ id: bobId, capabilities: scopeAlice.capabilities }))); await waitFor(() => expect(auth.user?.id).toBe(bobId)); expect(auth.user?.role).toBeUndefined(); }); diff --git a/apps/portal/src/contexts/AuthContext.tsx b/apps/portal/src/contexts/AuthContext.tsx index f91dc618d..380aef574 100644 --- a/apps/portal/src/contexts/AuthContext.tsx +++ b/apps/portal/src/contexts/AuthContext.tsx @@ -27,6 +27,7 @@ export interface AuthUser extends CurrentUserResponse { export type AuthStatus = "signed-out" | "resolving" | "ready" | "denied" | "error"; interface AuthContextValue { + authEnabled: boolean; account: AccountInfo | null; user: AuthUser | null; status: AuthStatus; @@ -63,6 +64,7 @@ interface Session { key: string; controller: AbortController; status: AuthStatus; + capabilities?: string; } interface Snapshot { @@ -140,6 +142,7 @@ export function AuthProvider({ children }: AuthProviderProps) { if (!isCurrent()) return; if (redirectLogin) consumeRedirectLogin(redirectLogin); current.status = "ready"; + current.capabilities = JSON.stringify(user.capabilities); setSnapshot({ key, status: "ready", user: toUser(account, user), error: null }); }, (cause: unknown) => { if (!isCurrent()) return; @@ -149,6 +152,41 @@ export function AuthProvider({ children }: AuthProviderProps) { }); }, [account, key, canResolve, attempt, clearSession]); + useEffect(() => { + if (!isAuthEnabled || !account || snapshot?.status !== "ready") return; + const current = session.current; + if (!current) return; + let running = false; + const timer = setInterval(() => { + if (running || current.controller.signal.aborted) return; + running = true; + void api.getCurrentUser({ signal: AbortSignal.any([current.controller.signal, AbortSignal.timeout(5_000)]) }) + .then(user => { + if (session.current !== current || current.controller.signal.aborted) return; + const next = toUser(account, user); + const capabilities = JSON.stringify(next.capabilities); + if (current.capabilities !== capabilities) { + void queryClient.cancelQueries(); + queryClient.clear(); + current.capabilities = capabilities; + } + setSnapshot(previous => { + if (!previous || previous.key !== current.key) return previous; + return { ...previous, user: next }; + }); + }, (cause: unknown) => { + if (session.current !== current || current.controller.signal.aborted) return; + const error = cause instanceof Error ? cause : new Error("Access refresh failed"); + current.status = cause instanceof ApiError && cause.status === 403 ? "denied" : "error"; + current.controller.abort(); + void queryClient.cancelQueries(); + queryClient.clear(); + setSnapshot({ key: current.key, status: current.status, user: null, error }); + }).finally(() => { running = false; }); + }, 10_000); + return () => clearInterval(timer); + }, [account, snapshot?.status, queryClient]); + const logout = useCallback(async () => { clearSession(); setSignedOut(true); @@ -170,6 +208,7 @@ export function AuthProvider({ children }: AuthProviderProps) { ? "resolving" : "signed-out"); const user = active?.user ?? null; const value: AuthContextValue = { + authEnabled: isAuthEnabled, account, user, status, diff --git a/apps/portal/src/contexts/FeatureFlagContext.tsx b/apps/portal/src/contexts/FeatureFlagContext.tsx index 640248c1e..02547c7fb 100644 --- a/apps/portal/src/contexts/FeatureFlagContext.tsx +++ b/apps/portal/src/contexts/FeatureFlagContext.tsx @@ -6,7 +6,6 @@ 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 */ @@ -25,7 +24,7 @@ const FeatureFlagContext = createContext({ export function FeatureFlagProvider({ children }: { children: ReactNode }) { const auth = useContext(AuthContext); - const enabled = !isAuthEnabled || Boolean(auth?.isReady && auth.isAuthenticated); + const enabled = Boolean(auth?.isReady && (!auth.authEnabled || auth.user?.capabilities?.platformAdmin)); const { data: flags = [], isLoading } = useQuery({ queryKey: ["feature-flags"], queryFn: api.listFeatureFlags, diff --git a/apps/portal/src/contexts/authFixtures.ts b/apps/portal/src/contexts/authFixtures.ts index b81dc7e1b..ab7c674ce 100644 --- a/apps/portal/src/contexts/authFixtures.ts +++ b/apps/portal/src/contexts/authFixtures.ts @@ -5,6 +5,7 @@ import type { AuthContextValue } from "./AuthContext"; /** Deterministic contexts for isolated stories/tests; never perform a handshake. */ export const signedOutAuth: AuthContextValue = { + authEnabled: true, account: null, user: null, status: "signed-out", @@ -16,6 +17,10 @@ export const signedOutAuth: AuthContextValue = { retry: () => {}, }; +export const anonymousAuth: AuthContextValue = { + ...signedOutAuth, authEnabled: false, status: "ready", isReady: true, +}; + export const signedInAuth: AuthContextValue = { ...signedOutAuth, status: "ready", @@ -32,6 +37,11 @@ export const signedInAuth: AuthContextValue = { user: { id: "11111111-2222-4333-8444-555555555555", role: "user", + capabilities: { + platformAdmin: false, reportsEnabled: false, + memberships: [{ projectId: "demo-project", role: "contributor", + permissions: ["content:read", "content:write", "content:delete"], revision: 1 }], + }, name: "Alice Anderson", displayName: "Alice Anderson", username: "alice@entralocal.dev", diff --git a/apps/portal/src/lib/api-client.ts b/apps/portal/src/lib/api-client.ts index f702f7d15..9ef71d576 100644 --- a/apps/portal/src/lib/api-client.ts +++ b/apps/portal/src/lib/api-client.ts @@ -94,6 +94,9 @@ export function resetApiClient(): void { } const authHook: BeforeRequestHook = async ({ request }) => { + if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !request.headers.has("Idempotency-Key")) { + request.headers.set("Idempotency-Key", crypto.randomUUID()); + } const signal = sessionSignal ? AbortSignal.any([request.signal, sessionSignal]) : request.signal; diff --git a/apps/portal/src/lib/api-current-user.test.ts b/apps/portal/src/lib/api-current-user.test.ts index fd1f0d753..66887e7c9 100644 --- a/apps/portal/src/lib/api-current-user.test.ts +++ b/apps/portal/src/lib/api-current-user.test.ts @@ -7,7 +7,8 @@ import { apiClient } from "./api-client"; vi.mock("./api-client", () => ({ apiClient: vi.fn() })); -const user = { id: "11111111-2222-4333-8444-555555555555", role: "user" }; +const user = { id: "11111111-2222-4333-8444-555555555555", role: "user", + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [] } }; beforeEach(() => { vi.resetAllMocks(); @@ -23,10 +24,28 @@ describe("current user API", () => { expect(await api[operation]({ signal: controller.signal })).toEqual(user); expect(apiClient).toHaveBeenCalledWith("/api/v1/users/me", { method, - headers: { "Content-Type": "application/json" }, + headers: expect.any(Headers), cache: "no-store", signal: controller.signal, }); + const headers = vi.mocked(apiClient).mock.calls[0][1]?.headers; + if (!(headers instanceof Headers)) throw new Error("Expected Headers"); + expect(headers.get("Content-Type")).toBe("application/json"); + }); + + it.each([ + undefined, + { platformAdmin: true, reportsEnabled: true, memberships: [] }, + { platformAdmin: "true", reportsEnabled: false, memberships: [] }, + { platformAdmin: false, reportsEnabled: false, memberships: [ + { projectId: "p", role: "reader", permissions: ["content:read", "content:write"], revision: 1 }, + ] }, + { platformAdmin: false, reportsEnabled: false, memberships: [ + { projectId: "p", role: "owner", permissions: ["*"], revision: 1 }, + ] }, + ])("fails closed on missing or malformed capabilities: %j", async capabilities => { + vi.mocked(apiClient).mockResolvedValue(new Response(JSON.stringify({ ...user, capabilities }))); + await expect(api.getCurrentUser()).rejects.toThrow("Invalid access capabilities"); }); it("keeps HTTP status and code without changing existing error messages", async () => { diff --git a/apps/portal/src/lib/api-platform-admins.test.ts b/apps/portal/src/lib/api-platform-admins.test.ts new file mode 100644 index 000000000..99bfe35e7 --- /dev/null +++ b/apps/portal/src/lib/api-platform-admins.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./api"; +import { apiClient } from "./api-client"; + +vi.mock("./api-client", () => ({ apiClient: vi.fn() })); + +beforeEach(() => vi.resetAllMocks()); + +describe("Platform administrator API contract", () => { + it("fetches a validated uncached page independently of project selection", async () => { + const page = { items: [{ id: "admin", role: "admin", revision: 1, disabled: false, pending: false }], nextCursor: "admin" }; + vi.mocked(apiClient).mockResolvedValue(Response.json(page)); + const controller = new AbortController(); + expect(await api.listPlatformAdmins({ cursor: "previous/admin", signal: controller.signal })).toEqual(page); + expect(apiClient).toHaveBeenCalledWith("/api/v1/admin/platform-admins?cursor=previous/admin", { + signal: controller.signal, cache: "no-store", headers: expect.any(Headers), + }); + }); + + it.each([ + { items: [{ id: "ordinary", role: "user", revision: 0, disabled: false, pending: false }] }, + { items: [{ id: "admin", role: "admin", revision: 1, disabled: false }] }, + { items: "invalid" }, + ])("rejects malformed administrator responses rather than showing an empty success: %j", async page => { + vi.mocked(apiClient).mockResolvedValue(Response.json(page)); + await expect(api.listPlatformAdmins()).rejects.toThrow(); + }); + + it("surfaces authority errors without silently substituting an empty roster", async () => { + vi.mocked(apiClient).mockResolvedValue(Response.json({ error: "Authority unavailable" }, { status: 503 })); + await expect(api.listPlatformAdmins()).rejects.toMatchObject({ status: 503, message: "Authority unavailable" }); + }); +}); diff --git a/apps/portal/src/lib/api-project-members.test.ts b/apps/portal/src/lib/api-project-members.test.ts new file mode 100644 index 000000000..d7dbe95dd --- /dev/null +++ b/apps/portal/src/lib/api-project-members.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "./api"; +import { apiClient } from "./api-client"; + +vi.mock("./api-client", () => ({ apiClient: vi.fn() })); + +beforeEach(() => vi.resetAllMocks()); + +describe("Project membership candidate search API contract", () => { + it("uses the explicit encoded project with query/cursor, cancellation and no-store", async () => { + const page = { items: [{ id: "candidate", role: "user", revision: 0, disabled: false }], nextCursor: "candidate" }; + vi.mocked(apiClient).mockResolvedValue(Response.json(page)); + const controller = new AbortController(); + expect(await api.searchProjectMembers("project/a", { + q: "Alice + Example", cursor: "previous/user", signal: controller.signal, + })).toEqual(page); + expect(apiClient).toHaveBeenCalledWith( + "/api/v1/projects/project%2Fa/members/search?q=Alice%20%2B%20Example&cursor=previous/user", + { signal: controller.signal, cache: "no-store", headers: expect.any(Headers) }, + ); + }); + + it("rejects malformed search pages instead of showing an empty success", async () => { + vi.mocked(apiClient).mockResolvedValue(Response.json({ items: [{ id: "candidate" }] })); + await expect(api.searchProjectMembers("project")).rejects.toThrow(); + }); + + it.each([403, 404, 503])("surfaces search authority/availability errors (%s)", async status => { + vi.mocked(apiClient).mockResolvedValue(Response.json({ error: "Search unavailable" }, { status })); + await expect(api.searchProjectMembers("project")).rejects.toMatchObject({ status, message: "Search unavailable" }); + expect(apiClient).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index eda367ba9..d20970f96 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -10,6 +10,11 @@ import { apiClient } from "./api-client"; import { downloadBatchArtifacts } from "./artifact-delivery"; import { getSelectedProjectId, ProjectRequiredError } from "./project-scope"; import { MAX_ARCHIVE_UPLOAD_LABEL } from "./codebaseUpload"; +import { + AccessCapabilitiesSchema, AccessUserSearchResultSchema, AccessUserSummarySchema, ProjectMembershipSummarySchema, + ApiAuthModeSchema, PlatformAdminListSchema, + type AccessCapabilities, type AccessUserSearchResult, type AccessUserSummary, type PlatformAdminList, type ProjectMembershipSummary, +} from "shared/rbac"; const BASE = "/api/v1"; @@ -21,6 +26,7 @@ export interface CurrentUserResponse { displayName?: string; idp?: string; idpTenant?: string; + capabilities: AccessCapabilities; } export class ApiError extends Error { @@ -42,7 +48,9 @@ function validateCurrentUser(value: unknown): CurrentUserResponse { ) { throw new Error("Invalid user response from Scope"); } - return value as CurrentUserResponse; + const capabilities = AccessCapabilitiesSchema.safeParse("capabilities" in value ? value.capabilities : undefined); + if (!capabilities.success) throw new Error("Invalid access capabilities from Scope"); + return { ...value, capabilities: capabilities.data } as CurrentUserResponse; } /** @@ -127,10 +135,9 @@ async function request(path: string, init?: RequestInit, opts?: RequestOpts): if (!projectId) throw new ProjectRequiredError(); finalPath = withProjectId(path, projectId); } - const res = await apiClient(`${BASE}${finalPath}`, { - headers: { "Content-Type": "application/json" }, - ...init, - }); + const headers = new Headers(init?.headers); + if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + const res = await apiClient(`${BASE}${finalPath}`, { ...init, headers }); // Sample the server's wall-clock from the standard HTTP `Date` header so // relative-time displays survive a misconfigured local clock. recordServerDate(res.headers.get("Date")); @@ -144,6 +151,10 @@ async function request(path: string, init?: RequestInit, opts?: RequestOpts): throw new ApiError(message, res.status, body.code); } if (res.status === 204) return undefined as T; + if (res.status === 202) { + const pending: { operationId?: string } = await res.json(); + throw new ApiError(`Operation ${pending.operationId ?? "unknown"} is pending operator repair; retry the identical change or contact an operator`, 202, "operation_pending"); + } return res.json(); } @@ -160,6 +171,24 @@ async function requestCurrentUser( } export const api = { + accessRequest: (path: string, method = "GET", body?: unknown, operationId?: string): Promise => + request(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: method === "GET" ? undefined : { "Idempotency-Key": operationId ?? crypto.randomUUID() } }), + searchAccessUsers: (options: { q?: string; cursor?: string; signal?: AbortSignal } = {}): Promise => + request(`/admin/users/search${qs({ q: options.q, cursor: options.cursor })}`, { signal: options.signal }) + .then(data => AccessUserSearchResultSchema.parse(data)), + listPlatformAdmins: (options: { cursor?: string; signal?: AbortSignal } = {}): Promise => + request(`/admin/platform-admins${qs({ cursor: options.cursor })}`, { signal: options.signal, cache: "no-store" }) + .then(data => PlatformAdminListSchema.parse(data)), + listProjectMembers: (projectId: string, signal?: AbortSignal): Promise => + request(`/projects/${encodeURIComponent(projectId)}/members`, { signal, cache: "no-store" }) + .then(data => ProjectMembershipSummarySchema.array().parse(data)), + lookupProjectMember: (projectId: string, userId: string, signal?: AbortSignal): Promise => + request(`/projects/${encodeURIComponent(projectId)}/members/lookup/${encodeURIComponent(userId)}`, { signal, cache: "no-store" }) + .then(data => AccessUserSummarySchema.parse(data)), + searchProjectMembers: (projectId: string, options: { q?: string; cursor?: string; signal?: AbortSignal } = {}): Promise => + request(`/projects/${encodeURIComponent(projectId)}/members/search${qs({ q: options.q, cursor: options.cursor })}`, { signal: options.signal, cache: "no-store" }) + .then(data => AccessUserSearchResultSchema.parse(data)), /** Only AuthProvider calls these; enrollment must never be prefetched. */ getCurrentUser: (opts: { signal?: AbortSignal } = {}): Promise => requestCurrentUser("GET", opts), @@ -518,7 +547,7 @@ export const api = { return request("/prompt-features/generate-prompt", { method: "POST", body: JSON.stringify({ behavior, ...(currentId && { currentId }) }), - }); + }, { scoped: true }); }, // ─── Task Prompts ────────────────────────────────────────────────────────── @@ -562,7 +591,7 @@ export const api = { return request("/task-prompts/generate", { method: "POST", body: JSON.stringify(opts), - }); + }, { scoped: true }); }, /** Extract prompt features for a task prompt entity */ @@ -579,7 +608,7 @@ export const api = { return request(`/prompt-features/extract-from-text`, { method: "POST", body: JSON.stringify({ text, ...(opts?.model && { model: opts.model }) }), - }); + }, { scoped: true }); }, /** Toggle a single feature's detected status on a task prompt */ @@ -834,12 +863,23 @@ export const api = { // ─── Version ─────────────────────────────────────────────────────────────── + /** Public bootstrap probe: never acquire a token or inherit an account's cancellation. */ + getAuthMode: async ({ signal }: { signal?: AbortSignal } = {}): Promise<{ authEnabled: boolean }> => { + const response = await fetch(new Request(new URL(`${BASE}/version`, window.location.origin), { + signal, cache: "no-store", credentials: "omit", + })); + if (!response.ok) throw new ApiError("Unable to read API authentication mode", response.status); + const value: unknown = await response.json(); + return ApiAuthModeSchema.parse(value); + }, + /** Get API version information (commit hash and build time) */ getVersion: (): Promise<{ commit: string; buildTime: string; environment?: string; strictAgentCapabilities?: boolean; + authEnabled?: boolean; }> => { return request("/version"); }, @@ -1391,9 +1431,8 @@ export const api = { // project (see `request(..., { scoped: true })`). /** List all projects (newest first). Pass `includeDeleted` to also return soft-deleted ones. */ - listProjects: (opts?: { includeDeleted?: boolean }): Promise => { - const qs = opts?.includeDeleted ? "?includeDeleted=true" : ""; - return request(`/projects${qs}`); + listProjects: (opts?: { includeDeleted?: boolean; controlPlane?: boolean }): Promise => { + return request(`/projects${qs({ includeDeleted: opts?.includeDeleted ? "true" : undefined, controlPlane: opts?.controlPlane ? "true" : undefined })}`); }, /** Get a single project by id (404 if missing). */ @@ -1402,31 +1441,33 @@ export const api = { }, /** Create a new project. */ - createProject: (body: CreateProjectRequest): Promise => { + createProject: (body: CreateProjectRequest, operationId?: string): Promise => { return request("/projects", { method: "POST", + ...(operationId ? { headers: { "Idempotency-Key": operationId } } : {}), body: JSON.stringify(body), }); }, /** Rename / re-describe a project (projectId is immutable). */ updateProject: (id: string, body: UpdateProjectRequest): Promise => { - return request(`/projects/${encodeURIComponent(id)}`, { - method: "PATCH", - body: JSON.stringify(body), - }); + return api.accessRequest(`/projects/${encodeURIComponent(id)}`, "PATCH", body); }, /** * Soft-delete a project. Always succeeds with **204** when the project exists * (even if it still owns scoped data); reversible via {@link restoreProject}. */ - deleteProject: (id: string): Promise => { - return request(`/projects/${encodeURIComponent(id)}`, { method: "DELETE" }); + deleteProject: async (id: string): Promise => { + const project = await api.getProject(id); + return api.accessRequest(`/projects/${encodeURIComponent(id)}`, "DELETE", { revision: project.revision ?? 0 }); }, /** Restore a soft-deleted project (clears its `deletedAt`). Returns the restored project. */ - restoreProject: (id: string): Promise => { - return request(`/projects/${encodeURIComponent(id)}/restore`, { method: "POST" }); + restoreProject: async (id: string): Promise => { + const projects = await api.listProjects({ controlPlane: true, includeDeleted: true }); + const project = projects.find(p => p._id === id); + if (!project) throw new Error("Project not found"); + return api.accessRequest(`/projects/${encodeURIComponent(id)}/restore`, "POST", { revision: project.revision ?? 0 }); }, }; diff --git a/apps/portal/src/lib/auth/authConfig.test.ts b/apps/portal/src/lib/auth/authConfig.test.ts index 0145944d5..e4ae45cdb 100644 --- a/apps/portal/src/lib/auth/authConfig.test.ts +++ b/apps/portal/src/lib/auth/authConfig.test.ts @@ -4,12 +4,7 @@ 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 -// per-environment controls: -// - local dev -> VITE_AUTH_ENABLED_LOCAL (build-time) -// - integration -> SCOPE_AUTH_ENABLED (runtime, __SCOPE_CONFIG__.authEnabled) -// - production -> SCOPE_AUTH_ENABLED (runtime, __SCOPE_CONFIG__.authEnabled) -// Resolution: runtime (int/prod) wins; else local vite flag (dev); else default. +// Runtime mode wins; local Vite maps SCOPE_AUTH_ENABLED to the legacy local flag. function env(overrides: Record): ImportMetaEnv { return overrides as unknown as ImportMetaEnv; } diff --git a/apps/portal/src/lib/auth/authConfig.ts b/apps/portal/src/lib/auth/authConfig.ts index 149d6e8c0..4fedf2fc7 100644 --- a/apps/portal/src/lib/auth/authConfig.ts +++ b/apps/portal/src/lib/auth/authConfig.ts @@ -30,6 +30,7 @@ import { ProtocolMode as MsalProtocolMode, type Configuration, } from "@azure/msal-browser"; +import { resolveAuthEnabledFlag } from "shared/rbac"; export type ProtocolMode = "AAD" | "OIDC"; @@ -49,17 +50,13 @@ export interface PortalAuthConfig { * Primary switch: `true` when the auth feature is turned on for this build. * * Auth is **on by default (secure by default)** and only turns off when an - * environment explicitly opts out. There are three independent per-environment - * controls (see {@link resolveAuthEnabled}): - * - **local dev** — `VITE_AUTH_ENABLED_LOCAL` (build-time). - * - **integration** — `SCOPE_AUTH_ENABLED` on the integration overlay - * (runtime, via `/config.js` → `window.__SCOPE_CONFIG__.authEnabled`). - * - **production** — `SCOPE_AUTH_ENABLED` on the prod overlay (same runtime - * mechanism). int/prod must be runtime because the image is promoted. + * environment explicitly opts out through SCOPE_AUTH_ENABLED. Vite maps that + * shell setting to VITE_AUTH_ENABLED_LOCAL; built images read /config.js at + * runtime so the same image can be promoted between environments. * * When `false`, the Portal renders exactly as it did before auth existed: no * sign-in gate, no account menu, and no `Authorization` header on API calls. - * This is the toggle to use until an environment's API ships token verification. + * The API must use the same mode through SCOPE_AUTH_ENABLED. */ enabled: boolean; /** @@ -101,29 +98,12 @@ function envList(value: string | undefined): string[] | undefined { return items.length ? items : undefined; } -/** - * Parse a boolean-ish env var. Accepts `true/1/yes/on` and `false/0/no/off` - * (case-insensitive); anything else (including undefined/empty) yields - * `fallback`, so a mis-set value fails safe to the default rather than silently - * disabling auth. - */ -function envBool(value: string | undefined, fallback: boolean): boolean { - if (value === undefined) return fallback; - const v = value.trim().toLowerCase(); - if (v === "true" || v === "1" || v === "yes" || v === "on") return true; - if (v === "false" || v === "0" || v === "no" || v === "off") return false; - return fallback; -} - /** * Resolve whether the auth feature is enabled for the current environment. * - * There are **three independent, per-environment controls** (local, integration, - * production). Auth is **on by default** (secure by default); a control must - * explicitly opt out. Because the production Portal image is **built once and - * promoted** int→prod (see the overlay `images.yaml` files + `promote.yml`), a - * build-time `VITE_` flag cannot differ between integration and production — so - * int/prod are governed at **runtime** while local uses a build-time flag: + * SCOPE_AUTH_ENABLED coordinates API and Portal mode. Auth is on by default. + * Built images use runtime configuration because the same image is promoted; + * native Vite maps the canonical shell setting to the legacy local flag. * * 1. **integration / production** — `window.__SCOPE_CONFIG__.authEnabled`, * written into `/config.js` at container start by @@ -133,8 +113,7 @@ function envBool(value: string | undefined, fallback: boolean): boolean { * obeys each environment's own setting. * 2. **local dev** — `VITE_AUTH_ENABLED_LOCAL` (build-time, read by `vite dev`). * Local `/config.js` ships no `authEnabled`, so resolution falls through to - * this flag. `false` disables auth for local iteration without standing up - * `entra-local`. + * this flag; Vite gives a shell SCOPE_AUTH_ENABLED precedence. * 3. **fallback** — a built bundle served without the entrypoint (no runtime * `authEnabled`) defaults to enabled, so a misconfigured deploy fails safe * to secured rather than open. @@ -147,10 +126,10 @@ export function resolveAuthEnabled( // 1) Per-environment runtime override (integration/production). const runtimeValue = runtime?.authEnabled; if (typeof runtimeValue === "boolean") return runtimeValue; - if (typeof runtimeValue === "string") return envBool(runtimeValue, true); + if (typeof runtimeValue === "string") return resolveAuthEnabledFlag(runtimeValue); // 2) Local dev build-time flag. if (isDev) { - return envBool(env.VITE_AUTH_ENABLED_LOCAL as string | undefined, true); + return resolveAuthEnabledFlag(env.VITE_AUTH_ENABLED_LOCAL as string | undefined); } // 3) Built bundle, no runtime config → secure default. return true; diff --git a/apps/portal/src/main.test.tsx b/apps/portal/src/main.test.tsx index c964a70a7..e2f38dbc7 100644 --- a/apps/portal/src/main.test.tsx +++ b/apps/portal/src/main.test.tsx @@ -77,21 +77,25 @@ it("the real bootstrap/provider tree sends no flags, favicon or page requests be const url = new URL(request.url); const path = url.pathname + url.search; requests.push(`${request.method} ${path}`); + if (path === "/api/v1/version") return Promise.resolve(Response.json({ authEnabled: true })); 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"])); + await waitFor(() => expect(requests).toEqual(["GET /api/v1/version", "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", + id: "11111111-2222-4333-8444-555555555555", role: "admin", + capabilities: { platformAdmin: true, reportsEnabled: false, memberships: [ + { projectId: "demo-project", role: "contributor", permissions: ["content:read", "content:write", "content:delete"], revision: 1 }, + ] }, })))); 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.slice(0, 2)).toEqual(["GET /api/v1/version", "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 cef225350..47925961b 100644 --- a/apps/portal/src/main.tsx +++ b/apps/portal/src/main.tsx @@ -14,6 +14,8 @@ import { AuthProvider } from "@/contexts/AuthContext"; import { ProjectProvider } from "@/contexts/ProjectContext"; import { Toaster } from "@/components/ui/sonner"; import { RequireAuth } from "@/components/auth/RequireAuth"; +import { AccessShell } from "@/components/AccessShell"; +import { AuthModeGate } from "@/components/auth/AuthModeGate"; import { msalInstance, initializeAuth } from "@/lib/auth/msalInstance"; import { wireApiAuth } from "@/lib/auth/wireApiAuth"; import "./index.css"; @@ -34,6 +36,7 @@ wireApiAuth(); initializeAuth().finally(() => { createRoot(document.getElementById("root")!).render( + @@ -42,7 +45,7 @@ initializeAuth().finally(() => { - + @@ -51,6 +54,7 @@ initializeAuth().finally(() => { + ); }); diff --git a/apps/portal/src/pages/AccessAdministration.stories.tsx b/apps/portal/src/pages/AccessAdministration.stories.tsx new file mode 100644 index 000000000..6e2c8d36e --- /dev/null +++ b/apps/portal/src/pages/AccessAdministration.stories.tsx @@ -0,0 +1,525 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { Link, useLocation } from "react-router-dom"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; +import { projectPermissions } from "shared/rbac"; +import type { AccessUserSummary, ProjectMembershipSummary, ProjectRole } from "shared/rbac"; +import { AccessAdministration } from "./AccessAdministration"; + +const projectId = "11111111-1111-4111-8111-111111111111"; +const alice: AccessUserSummary = { id: "22222222-2222-4222-8222-222222222222", displayName: "Alice Example", email: "alice@example.test", role: "admin", disabled: false, revision: 1 }; +const bob: AccessUserSummary = { id: "33333333-3333-4333-8333-333333333333", displayName: "Bob Example", email: "bob@example.test", role: "user", disabled: false, revision: 1 }; +const charlie: AccessUserSummary = { id: "44444444-4444-4444-8444-444444444444", displayName: "Charlie Example", email: "charlie@example.test", role: "user", disabled: false, revision: 1 }; +const disabled: AccessUserSummary = { ...charlie, id: "55555555-5555-4555-8555-555555555555", displayName: "Disabled user", disabled: true }; +const project = { _id: projectId, name: "Scope benchmarks", revision: 1, createdAt: "2026-01-01T00:00:00Z" }; +const platformAuth: AuthContextValue = { ...signedInAuth, user: { ...signedInAuth.user!, + id: alice.id, capabilities: { platformAdmin: true, reportsEnabled: false, memberships: [] } } }; +const projectAdminAuth: AuthContextValue = { ...signedInAuth, user: { ...signedInAuth.user!, id: alice.id, + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [ + { projectId, role: "admin", permissions: projectPermissions("admin"), revision: 1 }, + ] } } }; +function member(user: AccessUserSummary, role: ProjectRole, extra: Partial = {}): ProjectMembershipSummary { + return { _id: `${projectId}:${user.id}`, projectId, userId: user.id, role, revision: 1, pending: false, user, ...extra }; +} +function handlers(initial: ProjectMembershipSummary[] = [member(alice, "admin")], options: { + unavailable?: boolean; searchError?: boolean; pending?: boolean; pageSize?: number; + noProjects?: boolean; + onAdminList?: () => void; +} = {}) { + let members = [...initial]; + const users = [alice, bob, charlie, disabled]; + let retryId: string | null = null; + const searchUsers = ({ request }: { request: Request }) => { + if (options.searchError) return HttpResponse.json({ error: "Directory unavailable" }, { status: 503 }); + const url = new URL(request.url); + const query = url.searchParams.get("q")?.toLowerCase() ?? ""; + const matches = users.filter(user => + `${user.displayName} ${user.email} ${user.id}`.toLowerCase().includes(query), + ); + const cursor = url.searchParams.get("cursor"); + const start = cursor ? matches.findIndex(user => user.id === cursor) + 1 : 0; + const page = matches.slice(start, start + (options.pageSize ?? matches.length)); + return HttpResponse.json({ items: page, ...(start + page.length < matches.length ? { nextCursor: page[page.length - 1].id } : {}) }); + }; + return [ + http.get("/api/v1/projects", () => HttpResponse.json(options.noProjects ? [] : [project])), + http.get("/api/v1/projects/:id", () => HttpResponse.json(project)), + http.get("/api/v1/projects/:id/members/lookup/:userId", ({ params }) => { + const user = users.find(user => user.id === params.userId); + return !user ? HttpResponse.json({ error: "Enrolled user not found" }, { status: 404 }) + : user.disabled ? HttpResponse.json({ error: "Disabled users cannot receive new project access" }, { status: 409 }) + : HttpResponse.json(user); + }), + http.get("/api/v1/projects/:id/members/search", searchUsers), + http.get("/api/v1/admin/platform-admins", () => { + options.onAdminList?.(); + return HttpResponse.json({ items: [{ ...alice, pending: false }] }); + }), + http.get("/api/v1/projects/:id/members", () => options.unavailable + ? HttpResponse.json({ error: "Authority unavailable" }, { status: 503 }) + : HttpResponse.json(members)), + http.get("/api/v1/admin/users/search", searchUsers), + http.put<{ id: string; userId: string }, { role: ProjectRole; revision: number }>("/api/v1/projects/:id/members/:userId", async ({ request, params }) => { + const body: { role: ProjectRole; revision: number } = await request.json(); + const current = members.find(row => row.userId === params.userId); + if (body.revision !== (current?.revision ?? 0)) return HttpResponse.json({ error: "Revision mismatch" }, { status: 409 }); + const id = request.headers.get("Idempotency-Key"); + if (!id || (retryId && id !== retryId)) return HttpResponse.json({ error: "Retry changed the operation key" }, { status: 409 }); + if (options.pending && !retryId) { retryId = id; return HttpResponse.json({ operationId: id, state: "pending" }, { status: 202 }); } + const user = [alice, bob, charlie].find(user => user.id === params.userId); + if (!user) return HttpResponse.json({ error: "Unknown user" }, { status: 404 }); + members = [...members.filter(row => row.userId !== user.id), member(user, body.role, { revision: body.revision + 1 })]; + return HttpResponse.json({ operationId: id, state: "completed", revision: body.revision + 1 }); + }), + http.delete<{ id: string; userId: string }, { revision: number }>("/api/v1/projects/:id/members/:userId", async ({ request, params }) => { + const body: { revision: number } = await request.json(); + const current = members.find(row => row.userId === params.userId); + if (body.revision !== current?.revision) return HttpResponse.json({ error: "Revision mismatch" }, { status: 409 }); + members = members.map(row => row.userId === params.userId ? { ...row, revokedAt: "2026-01-02T00:00:00Z", revision: row.revision + 1 } : row); + return HttpResponse.json({ operationId: "remove-member", state: "completed", revision: body.revision + 1 }); + }), + ]; +} +const meta = { + component: AccessAdministration, + decorators: [(Story, context) => ], + tags: ["ai-generated", "needs-work"], + parameters: { msw: { handlers: handlers() } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const ProjectAdminAddsByName: Story = { + parameters: { projectAdmin: true, msw: { handlers: [ + http.get("/api/v1/admin/users/search", () => { throw new Error("Project admin must never query the global directory"); }), + http.get("/api/v1/projects", () => { throw new Error("Project admin must never query the control-plane inventory"); }), + ...handlers(), + ] } }, + render: () => <>Manage this project, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Manage this project" })); + await userEvent.click(await canvas.findByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await expect(dialog.getByText(/Find an existing Scope user/)).toBeVisible(); + await userEvent.type(dialog.getByLabelText("Search users"), "bob"); + await userEvent.click(await dialog.findByRole("button", { name: /Bob Example/ })); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), "admin"); + await userEvent.click(dialog.getByRole("button", { name: "Add to project" })); + await expect(await canvas.findByText("Bob Example")).toBeVisible(); + await expect(canvas.getAllByText("Project admin")).toHaveLength(2); + await expect(canvas.queryByRole("link", { name: "All project access" })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: /More actions/ })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + }, +}; + +export const ProjectAdminChangesAndRemovesMember: Story = { + parameters: { projectAdmin: true, msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader")]) } }, + render: () => <>Manage this project, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Manage this project" })); + for (const role of ["contributor", "admin", "reader"]) { + await userEvent.click(await canvas.findByRole("button", { name: "Change role for Bob Example" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), role); + await userEvent.click(dialog.getByRole("button", { name: "Save role" })); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); + await waitFor(() => expect(canvas.getByRole("button", { name: "Change role for Bob Example" })).toBeEnabled()); + } + await userEvent.click(canvas.getByRole("button", { name: "Remove Bob Example" })); + await userEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: /^Remove access/ })); + await waitFor(() => expect(canvas.queryByText("Bob Example")).not.toBeInTheDocument()); + }, +}; + +export const ProjectAdminSelfDemotionWarning: Story = { + parameters: { projectAdmin: true }, + render: () => <>Manage this project, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Manage this project" })); + await userEvent.click(await canvas.findByRole("button", { name: "Change role for Alice Example" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), "reader"); + await expect(dialog.getByRole("alert")).toHaveTextContent("You will lose membership-management access"); + await userEvent.click(dialog.getByRole("button", { name: "Cancel" })); + await userEvent.click(canvas.getByRole("button", { name: "Remove Alice Example" })); + await expect(within(await screen.findByRole("alertdialog")).getByText(/You will lose access/)).toBeVisible(); + }, +}; + +export const ProjectAdminSearchDisabledAndMissing: Story = { + parameters: { projectAdmin: true }, + render: () => <>Manage this project, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Manage this project" })); + await userEvent.click(await canvas.findByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.type(dialog.getByLabelText("Search users"), "disabled"); + await expect(await dialog.findByRole("button", { name: /Disabled user/ })).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeDisabled(); + await userEvent.clear(dialog.getByLabelText("Search users")); + await userEvent.type(dialog.getByLabelText("Search users"), "nobody-enrolled"); + await expect(await dialog.findByText("No users found.")).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeDisabled(); + }, +}; + +export const ProjectAdminSearchEmailAndPagination: Story = { + parameters: { projectAdmin: true, msw: { handlers: handlers(undefined, { pageSize: 1 }) } }, + render: () => <>Manage this project, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Manage this project" })); + await userEvent.click(await canvas.findByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.click(await dialog.findByRole("button", { name: "More users" })); + await userEvent.click(await dialog.findByRole("button", { name: /Bob Example/ })); + await userEvent.click(dialog.getByRole("button", { name: "Back to first page" })); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeDisabled(); + await userEvent.type(dialog.getByLabelText("Search users"), "bob@example.test"); + await userEvent.click(await dialog.findByRole("button", { name: /Bob Example/ })); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeEnabled(); + }, +}; + +export const ProjectAdminSearchUnavailable: Story = { + parameters: { projectAdmin: true, msw: { handlers: handlers(undefined, { searchError: true }) } }, + render: () => <>Manage this project, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Manage this project" })); + await userEvent.click(await canvas.findByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await expect(await dialog.findByRole("alert")).toHaveTextContent("Directory unavailable"); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeDisabled(); + await expect(dialog.getByRole("button", { name: "Try again" })).toBeEnabled(); + }, +}; + +export const ProjectAdminPendingRetry: Story = { + parameters: { projectAdmin: true, msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader")], { pending: true }) } }, + render: () => <>Manage this project, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Manage this project" })); + await userEvent.click(await canvas.findByRole("button", { name: "Change role for Bob Example" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), "admin"); + await userEvent.click(dialog.getByRole("button", { name: "Save role" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("pending operator repair"); + await expect(canvas.getByText(/contact an operator with operation ID/)).toBeVisible(); + await expect(canvas.queryByRole("link", { name: "All project access" })).not.toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Add people" })).toBeDisabled(); + await userEvent.click(canvas.getByRole("button", { name: "Retry change" })); + await expect(await canvas.findByRole("status")).toHaveTextContent("completed"); + await waitFor(() => expect(canvas.queryByRole("button", { name: "Retry change" })).not.toBeInTheDocument()); + }, +}; + +async function chooseProject() { + const selector = await screen.findByRole("combobox", { name: "Project" }); + const option = await screen.findByRole("option", { name: /Scope benchmarks/ }); + await waitFor(() => expect(selector).toBeEnabled()); + await userEvent.selectOptions(selector, option); + await expect(await within(await screen.findByRole("list", { name: "Project members" })).findByText("Alice Example")).toBeVisible(); +} +async function selectBob() { + await chooseProject(); + await userEvent.click(screen.getByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.type(dialog.getByLabelText("Search users"), "bob"); + const candidate = await dialog.findByRole("button", { name: /Bob Example/ }); + await userEvent.click(candidate); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), "contributor"); + await userEvent.click(dialog.getByRole("button", { name: "Add to project" })); +} + +export const CurrentMembers: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader"), member(charlie, "contributor", { revokedAt: "2026-01-02T00:00:00Z" })]) } }, + play: async ({ canvas }) => { + await chooseProject(); + await expect(canvas.getByText("Bob Example")).toBeVisible(); + await expect(canvas.getByText("Reader")).toBeVisible(); + await expect(canvas.getByText("Project admin")).toBeVisible(); + await expect(canvas.getByText("bob@example.test")).toBeVisible(); + await expect(canvas.queryByRole("textbox", { name: "Find a member" })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Charlie Example")).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: /Create/ })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + }, +}; + +export const AddPeople: Story = { + parameters: { msw: { handlers: handlers() } }, + play: async ({ canvas }) => { + await selectBob(); + await expect(await canvas.findByText("Bob Example")).toBeVisible(); + await expect(canvas.getByText("Contributor")).toBeVisible(); + await expect(canvas.getByRole("status")).toHaveTextContent("completed"); + await expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }, +}; + +export const ChangeMemberRole: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader")]) } }, + play: async ({ canvas }) => { + await chooseProject(); + await userEvent.click(canvas.getByRole("button", { name: "Change role for Bob Example" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), "contributor"); + await userEvent.click(dialog.getByRole("button", { name: "Save role" })); + await expect(await canvas.findByText("Contributor")).toBeVisible(); + }, +}; + +export const RemoveMember: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader")]) } }, + play: async ({ canvas }) => { + await chooseProject(); + await userEvent.click(canvas.getByRole("button", { name: "Remove Bob Example" })); + await userEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: /^Remove access/ })); + await expect(await canvas.findByRole("status")).toHaveTextContent("completed"); + await waitFor(() => expect(canvas.queryByText("Bob Example")).not.toBeInTheDocument()); + }, +}; + +export const RegrantRevokedMember: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader", { revokedAt: "2026-01-02T00:00:00Z", revision: 4 })]) } }, + play: async ({ canvas }) => { + await selectBob(); + await expect(await canvas.findByRole("status")).toHaveTextContent("completed"); + await expect(canvas.getByText("Bob Example")).toBeVisible(); + }, +}; + +export const MembershipAuthorityUnavailable: Story = { + parameters: { msw: { handlers: handlers([], { unavailable: true }) } }, + play: async ({ canvas }) => { + await userEvent.selectOptions(canvas.getByLabelText("Project"), await canvas.findByRole("option", { name: /Scope benchmarks/ })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Membership authority unavailable"); + await expect(canvas.getByRole("button", { name: "Add people" })).toBeDisabled(); + }, +}; + +export const DirectoryUnavailable: Story = { + parameters: { msw: { handlers: handlers(undefined, { searchError: true }) } }, + play: async () => { + await chooseProject(); + await userEvent.click(screen.getByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await expect(await dialog.findByRole("alert")).toHaveTextContent("User search unavailable"); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeDisabled(); + }, +}; + +export const PendingChangeRetry: Story = { + parameters: { msw: { handlers: handlers(undefined, { pending: true }) } }, + play: async ({ canvas }) => { + await selectBob(); + await expect(await canvas.findByRole("alert")).toHaveTextContent("pending operator repair"); + await expect(canvas.getByRole("button", { name: "Add people" })).toBeDisabled(); + await userEvent.click(canvas.getByRole("button", { name: "Retry change" })); + await expect(await canvas.findByText("Bob Example")).toBeVisible(); + await expect(canvas.getByRole("status")).toHaveTextContent("completed"); + }, +}; + +export const ExistingAndDisabledUsers: Story = { + parameters: { msw: { handlers: handlers() } }, + play: async () => { + await chooseProject(); + await userEvent.click(screen.getByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await expect(await dialog.findByRole("button", { name: /Alice Example/ })).toBeDisabled(); + await expect(dialog.getByRole("button", { name: /Disabled user/ })).toBeDisabled(); + }, +}; + +export const PaginatedUsers: Story = { + parameters: { msw: { handlers: handlers(undefined, { pageSize: 2 }) } }, + play: async () => { + await chooseProject(); + await userEvent.click(screen.getByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.click(await dialog.findByRole("button", { name: "More users" })); + await userEvent.click(await dialog.findByRole("button", { name: /Charlie Example/ })); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeEnabled(); + await userEvent.click(dialog.getByRole("button", { name: "Back to first page" })); + await expect(await dialog.findByRole("button", { name: /Bob Example/ })).toBeEnabled(); + await expect(dialog.getByRole("button", { name: "Add to project" })).toBeDisabled(); + await userEvent.type(dialog.getByLabelText("Search users"), "nobody-matches"); + await expect(await dialog.findByText("No users found.")).toBeVisible(); + }, +}; + +export const SignedAliasWithoutVerifiedEmail: Story = { + parameters: { msw: { handlers: handlers([member({ ...bob, email: undefined, alias: "bob.alias@example.test" }, "reader")]) } }, + play: async ({ canvas }) => { + await userEvent.selectOptions(canvas.getByLabelText("Project"), await canvas.findByRole("option", { name: /Scope benchmarks/ })); + await expect(await canvas.findByText("Bob Example")).toBeVisible(); + await expect(canvas.getByText("bob.alias@example.test")).toBeVisible(); + await expect(canvas.queryByText(bob.id)).not.toBeInTheDocument(); + }, +}; + +function AccessAtRoute() { + const { pathname } = useLocation(); + return pathname === "/access" ? : null; +} + +const specificProjectAdminRequests = fn(); +export const SpecificProject: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader")], { onAdminList: specificProjectAdminRequests }) } }, + render: () => <>Open project access, + play: async ({ canvas }) => { + specificProjectAdminRequests.mockClear(); + await userEvent.click(canvas.getByRole("link", { name: "Open project access" })); + await expect(await canvas.findByText(project.name)).toBeVisible(); + await expect(await within(await canvas.findByRole("list", { name: "Project members" })).findByText("Alice Example")).toBeVisible(); + await expect(canvas.queryByRole("combobox", { name: "Project" })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + await expect(specificProjectAdminRequests).not.toHaveBeenCalled(); + await userEvent.click(canvas.getByRole("button", { name: "Change role for Bob Example" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), "contributor"); + await userEvent.click(dialog.getByRole("button", { name: "Save role" })); + await expect(await canvas.findByRole("status")).toHaveTextContent("completed"); + await expect(canvas.getByText("Contributor")).toBeVisible(); + await expect(specificProjectAdminRequests).not.toHaveBeenCalled(); + await userEvent.click(canvas.getByRole("link", { name: "All project access" })); + await expect(await canvas.findByRole("combobox", { name: "Project" })).toBeVisible(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + await expect(specificProjectAdminRequests).not.toHaveBeenCalled(); + const selector = await canvas.findByRole("combobox", { name: "Project" }); + await userEvent.selectOptions(selector, projectId); + await expect(await within(await canvas.findByRole("list", { name: "Project members" })).findByText("Alice Example")).toBeVisible(); + await expect(canvas.getByRole("combobox", { name: "Project" })).toBeVisible(); + await userEvent.click(canvas.getByRole("link", { name: "Open project access" })); + await expect(await canvas.findByText(project.name)).toBeVisible(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + await expect(specificProjectAdminRequests).not.toHaveBeenCalled(); + }, +}; + +export const SpecificProjectPendingOperatorGuidance: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader", { pending: true, operationId: "pending-member-change" })]) } }, + render: () => <>Open project access, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Open project access" })); + await expect(await canvas.findByText("pending-member-change")).toBeVisible(); + await expect(canvas.getByText(/Contact an operator/)).toBeVisible(); + await expect(canvas.queryByLabelText("Operation ID")).not.toBeInTheDocument(); + await expect(canvas.queryByRole("link", { name: "Manage pending change in All project access" })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("link", { name: "All project access" })); + await chooseProject(); + await expect(canvas.getByText("pending-member-change")).toBeVisible(); + }, +}; + +export const SpecificProjectUnconfirmedChangeOperatorGuidance: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader")], { pending: true }) } }, + render: () => <>Open project access, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Open project access" })); + await userEvent.click(await canvas.findByRole("button", { name: "Change role for Bob Example" })); + const dialog = within(await screen.findByRole("dialog")); + await userEvent.selectOptions(dialog.getByLabelText("Project role"), "contributor"); + await userEvent.click(dialog.getByRole("button", { name: "Save role" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("pending operator repair"); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + const notice = canvas.getByText(/Completion is not confirmed/); + await expect(notice).toHaveTextContent("contact an operator with operation ID"); + await expect(notice.querySelector("code")?.textContent).toBeTruthy(); + await expect(within(notice).queryByRole("link")).not.toBeInTheDocument(); + await expect(canvas.queryByLabelText("Operation ID")).not.toBeInTheDocument(); + }, +}; + +export const PendingRemovalSurvivesNavigation: Story = { + parameters: { msw: { handlers: handlers([member(alice, "admin"), member(bob, "reader", { + pending: true, operationId: "pending-removal", revokedAt: "2026-01-02T00:00:00Z", + })]) } }, + play: async ({ canvas }) => { + await chooseProject(); + await expect(canvas.getByText("Bob Example")).toBeVisible(); + await expect(canvas.getByText("pending-removal")).toBeVisible(); + await expect(canvas.getByRole("button", { name: "Remove Bob Example" })).toBeDisabled(); + }, +}; + +export const MissingSpecificProject: Story = { + render: () => <>Open missing project, + play: async ({ canvas }) => { + await chooseProject(); + await userEvent.click(canvas.getByRole("link", { name: "Open missing project" })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("This project is unavailable"); + await expect(canvas.queryByRole("combobox", { name: "Project" })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("list", { name: "Project members" })).not.toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "Add people" })).toBeDisabled(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + }, +}; + +const otherProject = { ...project, _id: "66666666-6666-4666-8666-666666666666", name: "Other project" }; +export const SpecificProjectNavigation: Story = { + parameters: { msw: { handlers: [ + http.get("/api/v1/projects", () => HttpResponse.json([project, otherProject])), + http.get("/api/v1/projects/:id/members", ({ params }) => HttpResponse.json(params.id === projectId + ? [member(alice, "admin")] : [member(bob, "reader", { projectId: otherProject._id })])), + ...handlers(), + ] } }, + render: () => <> + First project + Second project + + , + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "First project" })); + await expect(await within(await canvas.findByRole("list", { name: "Project members" })).findByText("Alice Example")).toBeVisible(); + await userEvent.click(canvas.getByRole("link", { name: "Second project" })); + await expect(await canvas.findByText("Bob Example")).toBeVisible(); + await expect(canvas.getByText(otherProject.name)).toBeVisible(); + await expect(within(canvas.getByRole("list", { name: "Project members" })).queryByText("Alice Example")).not.toBeInTheDocument(); + await expect(canvas.queryByRole("combobox", { name: "Project" })).not.toBeInTheDocument(); + }, +}; + +export const RestoreDeletedProject: Story = { + parameters: { msw: { handlers: (() => { + let deletedAt: string | undefined = "2026-01-02T00:00:00Z"; + return [ + http.get("/api/v1/projects", () => HttpResponse.json([{ ...project, deletedAt }])), + http.post("/api/v1/projects/:id/restore", () => { + deletedAt = undefined; + return HttpResponse.json({ ...project, revision: 2 }); + }), + ...handlers(), + ]; + })() } }, + play: async ({ canvas }) => { + await chooseProject(); + await expect(canvas.getByRole("button", { name: "Add people" })).toBeDisabled(); + await userEvent.click(canvas.getByRole("button", { name: "Restore project" })); + await waitFor(() => expect(canvas.getByRole("button", { name: "Add people" })).toBeEnabled()); + await expect(canvas.queryByRole("button", { name: "Restore project" })).not.toBeInTheDocument(); + }, +}; + +const globalAdminRequests = fn(); +export const ProjectAccessOnly: Story = { + parameters: { msw: { handlers: handlers([], { noProjects: true, onAdminList: globalAdminRequests }) } }, + render: () => { globalAdminRequests.mockClear(); return ; }, + play: async ({ canvas }) => { + await waitFor(() => expect(canvas.getByRole("combobox", { name: "Project" })).toBeEnabled()); + await expect(canvas.getByRole("heading", { name: "Project members" })).toBeVisible(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: "Add platform admin" })).not.toBeInTheDocument(); + await expect(globalAdminRequests).not.toHaveBeenCalled(); + }, +}; diff --git a/apps/portal/src/pages/AccessAdministration.test.tsx b/apps/portal/src/pages/AccessAdministration.test.tsx new file mode 100644 index 000000000..8965e056f --- /dev/null +++ b/apps/portal/src/pages/AccessAdministration.test.tsx @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, it, vi } from "vitest"; +import { projectPermissions, type ProjectMembershipSummary, type ProjectRole } from "shared/rbac"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { signedInAuth } from "@/contexts/authFixtures"; +import { ProjectProvider, useProjectContext } from "@/contexts/ProjectContext"; +import { api } from "@/lib/api"; +import { AccessAdministration } from "./AccessAdministration"; + +const projectId = "11111111-1111-4111-8111-111111111111"; +const userId = "22222222-2222-4222-8222-222222222222"; +const user = { id: userId, role: "user", disabled: false, revision: 0, displayName: "Project administrator" }; +const members: ProjectMembershipSummary[] = [{ + _id: `${projectId}:${userId}`, projectId, userId, role: "admin", revision: 1, pending: false, user, +}]; +const clients: QueryClient[] = []; +afterEach(() => { + cleanup(); vi.restoreAllMocks(); localStorage.clear(); + clients.splice(0).forEach(client => client.clear()); +}); +function auth(role: ProjectRole = "admin", id = userId): AuthContextValue { + return { ...signedInAuth, user: { ...signedInAuth.user!, id, + capabilities: { platformAdmin: false, reportsEnabled: false, memberships: [ + { projectId, role, permissions: projectPermissions(role), revision: 1 }, + ] } } }; +} +function fixture() { + vi.spyOn(api, "getProject").mockResolvedValue({ _id: projectId, name: "Project A", createdAt: "2026-01-01T00:00:00Z" }); + vi.spyOn(api, "listProjectMembers").mockResolvedValue(members); + const scopedDirectory = vi.spyOn(api, "searchProjectMembers").mockResolvedValue({ items: [] }); + const directory = vi.spyOn(api, "searchAccessUsers"); + const inventory = vi.spyOn(api, "listProjects"); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + function SwitchProject() { + const { setSelectedProjectId } = useProjectContext(); + return ; + } + function tree(value: AuthContextValue) { + return + + + + ; + } + return { tree, directory, scopedDirectory, inventory }; +} + +it("closes an open editor immediately when project-admin authority is removed", async () => { + const { tree } = fixture(); + const view = render(tree(auth())); + await waitFor(() => expect(screen.getByRole("button", { name: "Add people" }).hasAttribute("disabled")).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Add people" })); + expect(await screen.findByRole("dialog")).toBeTruthy(); + view.rerender(tree(auth("reader"))); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.queryByRole("list", { name: "Project members" })).toBeNull(); + expect(screen.getByText("Project membership administration is not permitted.")).toBeTruthy(); +}); + +it("resets editor state on account changes and never requests global inventory or directory", async () => { + const { tree, directory, scopedDirectory, inventory } = fixture(); + const view = render(tree(auth())); + await waitFor(() => expect(screen.getByRole("button", { name: "Add people" }).hasAttribute("disabled")).toBe(false)); + fireEvent.click(screen.getByRole("button", { name: "Add people" })); + const dialog = within(await screen.findByRole("dialog")); + fireEvent.change(dialog.getByLabelText("Search users"), { target: { value: "unsubmitted-old-account-text" } }); + expect(scopedDirectory).toHaveBeenCalledWith(projectId, expect.objectContaining({ signal: expect.any(AbortSignal) })); + view.rerender(tree(auth("admin", "new-account"))); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(directory).not.toHaveBeenCalled(); + expect(inventory).not.toHaveBeenCalled(); +}); + +it("keeps a membership deep link pinned to its explicit project across selected-project changes", async () => { + const { tree } = fixture(); + render(tree(auth())); + await screen.findByText("Project administrator"); + fireEvent.click(screen.getByRole("button", { name: "Switch selected project" })); + expect(screen.getByText("Project A")).toBeTruthy(); + expect(api.getProject).toHaveBeenCalledWith(projectId); + expect(api.listProjectMembers).toHaveBeenCalledWith(projectId, expect.any(AbortSignal)); + expect(api.getProject).not.toHaveBeenCalledWith("unrelated-selected-project"); +}); + +it("does not mount project queries while account authority is unavailable", () => { + const { tree, directory, inventory } = fixture(); + render(tree({ ...auth(), isReady: false, status: "resolving" })); + expect(api.getProject).not.toHaveBeenCalled(); + expect(api.listProjectMembers).not.toHaveBeenCalled(); + expect(directory).not.toHaveBeenCalled(); + expect(inventory).not.toHaveBeenCalled(); +}); + +it("does not expose project restoration to a project admin even if an authorized in-flight read returns deleted metadata", async () => { + const { tree } = fixture(); + vi.mocked(api.getProject).mockResolvedValue({ + _id: projectId, name: "Project A", createdAt: "2026-01-01T00:00:00Z", deletedAt: "2026-01-02T00:00:00Z", + }); + render(tree(auth())); + await screen.findByText("This project is deleted. Restore it before changing access."); + expect(screen.queryByRole("button", { name: "Restore project" })).toBeNull(); + expect(screen.getByRole("button", { name: "Add people" }).hasAttribute("disabled")).toBe(true); +}); diff --git a/apps/portal/src/pages/AccessAdministration.tsx b/apps/portal/src/pages/AccessAdministration.tsx new file mode 100644 index 000000000..8bb33dfbd --- /dev/null +++ b/apps/portal/src/pages/AccessAdministration.tsx @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Ellipsis, Loader2, Plus, ShieldCheck, Users } from "lucide-react"; +import type { AccessUserSummary, ProjectMembershipSummary, ProjectRole } from "shared/rbac"; +import { api } from "@/lib/api"; +import { useProjectContext } from "@/contexts/ProjectContext"; +import { useAuth } from "@/contexts/AuthContext"; +import { AccessUserDialog, Person, roleLabels, userName } from "@/components/AccessUserDialog"; +import { AccessOperationStatus, PendingAccessChange, useAccessOperation } from "@/components/AccessOperation"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, + AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; + +export function AccessAdministration() { + const [params] = useSearchParams(); + const { user, authEnabled, isReady } = useAuth(); + const fixedProjectId = params.has("projectId") ? params.get("projectId") ?? "" : undefined; + const platformAdmin = Boolean(user?.capabilities.platformAdmin); + const canManage = platformAdmin || Boolean(fixedProjectId && user?.capabilities.memberships.some( + m => m.projectId === fixedProjectId && m.role === "admin" && m.permissions.includes("members:write"), + )); + if (!authEnabled || !isReady || !user || !canManage) return
Project membership administration is not permitted.
; + return ; +} + +function AccessAdministrationView({ fixedProjectId, platformAdmin, selfUserId }: { + fixedProjectId?: string; platformAdmin: boolean; selfUserId: string; +}) { + const { selectedProjectId } = useProjectContext(); + const [chosenProjectId, setChosenProjectId] = useState(selectedProjectId ?? ""); + const scoped = fixedProjectId !== undefined; + const projectId = fixedProjectId ?? chosenProjectId; + const [editor, setEditor] = useState<{ user?: AccessUserSummary; role?: ProjectRole }>(); + const [confirmation, setConfirmation] = useState<{ member: ProjectMembershipSummary; disable: boolean }>(); + const operation = useAccessOperation(() => { setEditor(undefined); setConfirmation(undefined); }); + const { setMessage, setError, busy, pending, refresh, execute } = operation; + const projectsQuery = useQuery({ + queryKey: ["access-projects", platformAdmin ? "platform" : selfUserId, platformAdmin ? undefined : projectId], + queryFn: async () => platformAdmin + ? api.listProjects({ controlPlane: true, includeDeleted: true }) + : [await api.getProject(projectId)], + retry: false, + }); + const selectedProject = projectsQuery.data?.find(project => project._id === projectId); + const membersQuery = useQuery({ + queryKey: ["access-members", projectId], + enabled: Boolean(selectedProject), + queryFn: ({ signal }) => api.listProjectMembers(projectId, signal), + retry: false, + }); + const members = membersQuery.data ?? []; + const current = members.filter(member => !member.revokedAt || member.pending); + const ready = Boolean(selectedProject && !selectedProject.deletedAt && membersQuery.isSuccess && !membersQuery.isFetching && !projectsQuery.isFetching); + const restoreMutation = useMutation({ + mutationFn: () => api.restoreProject(projectId), + onSuccess: () => { setError(""); setMessage("Project restored."); void refresh(); }, + onError: cause => { setMessage(""); setError(cause.message); }, + }); + const locked = busy || Boolean(pending) || restoreMutation.isPending; + return
+
+
+

Access administration

+

Manage who has access to your projects and what they can do.

+
+
+ {scoped && platformAdmin && } + +
+
+
+ {scoped ?
+

Project

+

{selectedProject?.name ?? (projectsQuery.isPending ? "Loading project..." : "Project unavailable")}

+
: <> + + + } + {projectsQuery.isError &&

Project inventory unavailable: {projectsQuery.error.message}

} + {projectsQuery.isSuccess && (scoped || projectId) && !selectedProject &&

+ This project is unavailable.{scoped && platformAdmin ? " Open All project access to choose another project." : " Choose another project from Your projects."} +

} +
+ +
+
+

+ +
+ {!selectedProject ?
{scoped ? "Project membership is unavailable." : "Select a project to view its members."}
+ : membersQuery.isError ?
+ Membership authority unavailable: {membersQuery.error.message}{" "} + +
: membersQuery.isPending ?

+ : <> + {selectedProject.deletedAt &&
+

This project is deleted. Restore it before changing access.

+ {platformAdmin && } +
} +
    + {current.map(member =>
  • + +
    + {member.user?.disabled && Account disabled} + {member.pending && } + {roleLabels[member.role]} + + + {platformAdmin && member.user?.role === "user" && !member.user.disabled && + + setConfirmation({ member, disable: true })}>Disable account + } +
    +
  • )} + {!current.length &&
  • +
  • } +
+ } +
+

+ {editor && setEditor(undefined)} + onSave={(user, role) => { + const membership = members.find(member => member.userId === user.id); + void execute({ + id: crypto.randomUUID(), path: `/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(user.id)}`, method: "PUT", + body: { role, revision: membership?.revision ?? 0 }, description: `${userName(user)}'s ${roleLabels[role]} access`, + }); + }} />} + { if (!open) setConfirmation(undefined); }}> + + + {confirmation?.disable ? "Disable this account?" : "Remove project access?"} + {confirmation?.disable + ? "This disables the ordinary user's access across the entire platform. Operator recovery is required to re-enable the account." + : !platformAdmin && confirmation?.member.userId === selfUserId + ? "You will lose access to this project, including membership management. Another project admin or a platform administrator must restore it. No other project admin is required. Content and accepted runs stay with the project." + : "This person will lose access to this project. Their content and accepted runs stay with the project."} + + + Cancel + { + if (!confirmation) return; + const { member, disable } = confirmation; + const revision = disable ? member.user?.revision : member.revision; + if (revision === undefined) { setError("User details are unavailable. Refresh the member list before disabling this account."); return; } + void execute({ + id: crypto.randomUUID(), + path: disable ? `/admin/users/${encodeURIComponent(member.userId)}/disable` : `/projects/${encodeURIComponent(projectId)}/members/${encodeURIComponent(member.userId)}`, + method: disable ? "POST" : "DELETE", body: { revision }, + description: disable ? "Account disable" : "Project membership removal", + }); + }}>{confirmation?.disable ? "Disable account" : "Remove access"} + + + +
; +} diff --git a/apps/portal/src/pages/Admin.stories.tsx b/apps/portal/src/pages/Admin.stories.tsx new file mode 100644 index 000000000..907d1ddf1 --- /dev/null +++ b/apps/portal/src/pages/Admin.stories.tsx @@ -0,0 +1,250 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { Link } from "react-router-dom"; +import type { AccessUserSummary, PlatformAdminSummary } from "shared/rbac"; +import { AuthContext } from "@/contexts/AuthContext"; +import { anonymousAuth, signedInAuth } from "@/contexts/authFixtures"; +import { AccessShell } from "@/components/AccessShell"; +import { Admin } from "./Admin"; + +const alice: AccessUserSummary = { id: "22222222-2222-4222-8222-222222222222", displayName: "Alice Example", email: "alice@example.test", role: "admin", disabled: false, revision: 1 }; +const bob: AccessUserSummary = { id: "33333333-3333-4333-8333-333333333333", displayName: "Bob Example", email: "bob@example.test", role: "user", disabled: false, revision: 1 }; +const charlie: AccessUserSummary = { id: "44444444-4444-4444-8444-444444444444", displayName: "Charlie Example", role: "admin", disabled: true, revision: 1 }; +const disabled: AccessUserSummary = { ...bob, id: "55555555-5555-4555-8555-555555555555", displayName: "Disabled user", disabled: true }; +const adminAuth = { ...signedInAuth, user: signedInAuth.user && { ...signedInAuth.user, role: "admin" as const, + capabilities: { ...signedInAuth.user.capabilities, platformAdmin: true, memberships: [] } } }; + +function handlers(options: { + adminPending?: boolean; promotionConflict?: boolean; adminUnavailable?: boolean; adminPageSize?: number; + pendingAdmin?: boolean; onAdminList?: () => void; onProjects?: () => void; onPromotion?: () => void; +} = {}) { + let users = [alice, bob, charlie, disabled]; + let admins: PlatformAdminSummary[] = [{ ...alice, role: "admin", pending: Boolean(options.pendingAdmin), + ...(options.pendingAdmin ? { operationId: "pending-platform-promotion" } : {}) }, + ...(options.adminPageSize ? [{ ...charlie, role: "admin" as const, pending: false }] : [])]; + let promotionId: string | null = null; + let flag = { key: "reports", label: "Reports", enabled: true }; + return [ + http.get("/api/v1/projects", () => { options.onProjects?.(); return HttpResponse.json([]); }), + http.get("/api/v1/feature-flags", () => HttpResponse.json([flag])), + http.put<{ key: string }, { enabled: boolean }>("/api/v1/feature-flags/:key", async ({ request }) => { + const body: { enabled: boolean } = await request.json(); + flag = { ...flag, enabled: body.enabled }; + return HttpResponse.json(flag); + }), + http.get("/api/v1/admin/platform-admins", ({ request }) => { + options.onAdminList?.(); + if (options.adminUnavailable) return HttpResponse.json({ error: "Authority unavailable" }, { status: 503 }); + const cursor = new URL(request.url).searchParams.get("cursor"); + const start = cursor ? admins.findIndex(user => user.id === cursor) + 1 : 0; + const page = admins.slice(start, start + (options.adminPageSize ?? 20)); + return HttpResponse.json({ items: page, ...(start + page.length < admins.length ? { nextCursor: page[page.length - 1].id } : {}) }); + }), + http.get("/api/v1/admin/users/search", ({ request }) => { + const query = new URL(request.url).searchParams.get("q")?.toLowerCase() ?? ""; + return HttpResponse.json({ items: users.filter(user => `${user.displayName} ${user.email} ${user.id}`.toLowerCase().includes(query)) }); + }), + http.post<{ id: string }, { revision: number }>("/api/v1/admin/users/:id/promote", async ({ request, params }) => { + options.onPromotion?.(); + const body: { revision: number } = await request.json(); + const id = request.headers.get("Idempotency-Key"); + const user = users.find(user => user.id === params.id); + if (!id || (promotionId && promotionId !== id)) return HttpResponse.json({ error: "Retry changed the operation key" }, { status: 409 }); + if (!user || user.disabled || user.role !== "user" || body.revision !== user.revision || options.promotionConflict) { + return HttpResponse.json({ error: "Target revision or preconditions changed" }, { status: 409 }); + } + if (options.adminPending && !promotionId) { + promotionId = id; + return HttpResponse.json({ operationId: id, state: "pending", revision: body.revision + 1 }, { status: 202 }); + } + const promoted = { ...user, role: "admin" as const, revision: user.revision + 1 }; + users = users.map(row => row.id === user.id ? promoted : row); + admins = [...admins, { ...promoted, pending: false, operationId: id }]; + return HttpResponse.json({ operationId: id, state: "completed", revision: promoted.revision }); + }), + ]; +} + +const meta = { + component: Admin, + tags: ["ai-generated", "needs-work"], + decorators: [(Story) => ], + parameters: { msw: { handlers: handlers() } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +async function reviewPromotion() { + await userEvent.click(await screen.findByRole("button", { name: "Add platform admin" })); + const dialog = within(await screen.findByRole("dialog")); + await expect(await dialog.findByRole("button", { name: /Alice Example/ })).toBeDisabled(); + await expect(dialog.getByRole("button", { name: /Disabled user/ })).toBeDisabled(); + await expect(dialog.queryByLabelText("Project role")).not.toBeInTheDocument(); + await userEvent.type(dialog.getByLabelText("Search users"), "bob"); + await userEvent.click(await dialog.findByRole("button", { name: /Bob Example/ })); + await userEvent.click(dialog.getByRole("button", { name: "Review promotion" })); + const confirmation = within(await screen.findByRole("alertdialog")); + await expect(confirmation.getByText(/across the entire platform/)).toBeVisible(); + return confirmation; +} + +const projectRequests = fn(); +export const PlatformAdminsWithoutProjects: Story = { + parameters: { msw: { handlers: handlers({ onProjects: projectRequests }) } }, + play: async ({ canvas }) => { + projectRequests.mockClear(); + const section = within(canvas.getByRole("region", { name: "Platform administrators" })); + await expect(await section.findByText("Alice Example")).toBeVisible(); + await expect(section.getByRole("button", { name: "Add platform admin" })).toBeEnabled(); + const heading = canvas.getByRole("heading", { name: "Platform administrators" }); + await expect(canvas.getByRole("heading", { name: /^Admin$/ }).compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await expect(heading.compareDocumentPosition(canvas.getByText("Feature Flags")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + await expect(canvas.queryByLabelText("Project")).not.toBeInTheDocument(); + await expect(projectRequests).not.toHaveBeenCalled(); + }, +}; + +const promotionRequests = fn(); +export const AddPlatformAdmin: Story = { + parameters: { msw: { handlers: handlers({ onPromotion: promotionRequests }) } }, + play: async ({ canvas }) => { + promotionRequests.mockClear(); + const section = within(canvas.getByRole("region", { name: "Platform administrators" })); + const confirmation = await reviewPromotion(); + await expect(section.queryByText("Bob Example")).not.toBeInTheDocument(); + await userEvent.click(confirmation.getByRole("button", { name: "Cancel" })); + await expect(promotionRequests).not.toHaveBeenCalled(); + const confirmed = await reviewPromotion(); + await userEvent.click(confirmed.getByRole("button", { name: /^Grant platform admin/ })); + await expect(await section.findByRole("status")).toHaveTextContent("platform-admin promotion completed"); + await expect(await section.findByText("Bob Example")).toBeVisible(); + await expect(promotionRequests).toHaveBeenCalledTimes(1); + await expect(section.queryByRole("button", { name: /Remove|Demote/ })).not.toBeInTheDocument(); + }, +}; + +export const PendingPlatformPromotionRetry: Story = { + parameters: { msw: { handlers: handlers({ adminPending: true }) } }, + play: async ({ canvas }) => { + const confirmation = await reviewPromotion(); + await userEvent.click(confirmation.getByRole("button", { name: /^Grant platform admin/ })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("pending operator repair"); + await expect(canvas.getByRole("button", { name: "Add platform admin" })).toBeDisabled(); + await expect(canvas.queryByText("Bob Example")).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Retry change" })); + await expect(await canvas.findByText("Bob Example")).toBeVisible(); + await expect(canvas.getByRole("status")).toHaveTextContent("completed"); + }, +}; + +export const PlatformPromotionConflict: Story = { + parameters: { msw: { handlers: handlers({ promotionConflict: true }) } }, + play: async ({ canvas }) => { + const confirmation = await reviewPromotion(); + await userEvent.click(confirmation.getByRole("button", { name: /^Grant platform admin/ })); + await expect(await canvas.findByRole("alert")).toHaveTextContent("Target revision or preconditions changed"); + await expect(canvas.queryByRole("button", { name: "Retry change" })).not.toBeInTheDocument(); + await expect(canvas.queryByText("Bob Example")).not.toBeInTheDocument(); + await waitFor(() => expect(canvas.getByRole("button", { name: "Add platform admin" })).toBeEnabled()); + }, +}; + +export const PlatformAdminAuthorityUnavailable: Story = { + parameters: { msw: { handlers: handlers({ adminUnavailable: true }) } }, + play: async ({ canvas }) => { + const section = within(canvas.getByRole("region", { name: "Platform administrators" })); + await expect(await section.findByRole("alert")).toHaveTextContent("Platform administrator list unavailable"); + await expect(section.getByRole("button", { name: "Add platform admin" })).toBeDisabled(); + await expect(section.queryByText("No platform administrators on this page.")).not.toBeInTheDocument(); + }, +}; + +export const PaginatedPlatformAdmins: Story = { + parameters: { msw: { handlers: handlers({ adminPageSize: 1 }) } }, + play: async ({ canvas }) => { + const section = within(canvas.getByRole("region", { name: "Platform administrators" })); + await expect(await section.findByText("Alice Example")).toBeVisible(); + await userEvent.click(section.getByRole("button", { name: "More administrators" })); + await expect(await section.findByText("Charlie Example")).toBeVisible(); + await expect(section.getByText("Account disabled")).toBeVisible(); + await expect(section.getByText("Platform admin (inactive)")).toBeVisible(); + await userEvent.click(section.getByRole("button", { name: "Back to first admin page" })); + await expect(await section.findByText("Alice Example")).toBeVisible(); + }, +}; + +export const PendingPlatformAdminOperatorGuidance: Story = { + parameters: { msw: { handlers: handlers({ pendingAdmin: true }) } }, + play: async ({ canvas }) => { + const section = within(canvas.getByRole("region", { name: "Platform administrators" })); + await expect(await section.findByText("Change pending")).toBeVisible(); + await expect(section.getByText("Platform admin (inactive)")).toBeVisible(); + await expect(section.getByText("pending-platform-promotion")).toBeVisible(); + await expect(section.getByText(/Contact an operator/)).toBeVisible(); + await expect(canvas.queryByLabelText("Operation ID")).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: /Inspect operation|Reconcile operation/ })).not.toBeInTheDocument(); + }, +}; + +export const LegacyPlatformRecoveryLinkIgnored: Story = { + render: () => <>Open promotion recovery, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Open promotion recovery" })); + await expect(await canvas.findByRole("heading", { name: "Platform administrators" })).toBeVisible(); + await expect(canvas.queryByLabelText("Operation ID")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Advanced: operation recovery")).not.toBeInTheDocument(); + }, +}; + +export const FeatureFlagsStillEditable: Story = { + play: async ({ canvas }) => { + const toggle = await canvas.findByRole("switch", { name: "Reports" }); + await expect(toggle).toBeChecked(); + await userEvent.click(toggle); + await waitFor(() => expect(toggle).not.toBeChecked()); + }, +}; + +const disabledAdminRequests = fn(); +export const AuthenticationDisabled: Story = { + parameters: { msw: { handlers: handlers({ onAdminList: disabledAdminRequests }) } }, + render: () => { + disabledAdminRequests.mockClear(); + return ; + }, + play: async ({ canvas }) => { + await expect(await canvas.findByRole("switch", { name: "Reports" })).toBeEnabled(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + await expect(canvas.queryByRole("button", { name: "Add platform admin" })).not.toBeInTheDocument(); + await expect(disabledAdminRequests).not.toHaveBeenCalled(); + }, +}; + +export const ResolvingAdminCapabilities: Story = { + parameters: { msw: { handlers: handlers({ onAdminList: disabledAdminRequests }) } }, + render: () => { + disabledAdminRequests.mockClear(); + return + Open Admin + ; + }, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Open Admin" })); + await expect(canvas.getByText("Access capabilities are unavailable.")).toBeVisible(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + await expect(disabledAdminRequests).not.toHaveBeenCalled(); + }, +}; + +export const MemberCannotOpenAdmin: Story = { + render: () => Open Admin, + play: async ({ canvas }) => { + await userEvent.click(canvas.getByRole("link", { name: "Open Admin" })); + await expect(canvas.getByText("Platform administration is not permitted.")).toBeVisible(); + await expect(canvas.queryByRole("region", { name: "Platform administrators" })).not.toBeInTheDocument(); + }, +}; diff --git a/apps/portal/src/pages/Admin.tsx b/apps/portal/src/pages/Admin.tsx index 248d7c97d..ece529b57 100644 --- a/apps/portal/src/pages/Admin.tsx +++ b/apps/portal/src/pages/Admin.tsx @@ -10,9 +10,13 @@ import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Settings } from "lucide-react"; import { toast } from "sonner"; +import { useAuth } from "@/contexts/AuthContext"; +import { PlatformAdministrators } from "@/components/PlatformAdministrators"; export function Admin() { const queryClient = useQueryClient(); + const { authEnabled, isReady, user } = useAuth(); + const canManageIdentities = authEnabled && isReady && Boolean(user?.capabilities?.platformAdmin); const { data: flags = [], isLoading } = useQuery({ queryKey: ["feature-flags"], @@ -39,6 +43,8 @@ export function Admin() {

Admin

+ {canManageIdentities && } + {/* Feature Flags card */} diff --git a/apps/portal/src/pages/Projects.stories.tsx b/apps/portal/src/pages/Projects.stories.tsx new file mode 100644 index 000000000..d7fe45a27 --- /dev/null +++ b/apps/portal/src/pages/Projects.stories.tsx @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { expect, userEvent, screen } from "storybook/test"; +import { AuthContext, type AuthContextValue } from "@/contexts/AuthContext"; +import { anonymousAuth, signedInAuth } from "@/contexts/authFixtures"; +import { Projects } from "./Projects"; + +const project = { + _id: "demo-project", id: "demo-project", name: "Newly created project", + createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", revision: 1, +}; +const adminAuth: AuthContextValue = { + ...signedInAuth, + user: { + ...signedInAuth.user!, + role: "admin" as const, + capabilities: { platformAdmin: true, reportsEnabled: false, memberships: [] }, + }, +}; +const readerAuth: AuthContextValue = { + ...signedInAuth, + user: { + ...signedInAuth.user!, + capabilities: { + platformAdmin: false, reportsEnabled: false, + memberships: [{ projectId: project._id, role: "reader" as const, permissions: ["content:read" as const], revision: 1 }], + }, + }, +}; +const creatorAuth: AuthContextValue = { + ...adminAuth, + user: { + ...adminAuth.user!, + capabilities: { + platformAdmin: true, reportsEnabled: false, + memberships: [{ + projectId: project._id, role: "admin", + permissions: ["content:read", "content:write", "content:delete", "settings:write", "members:write"], revision: 1, + }], + }, + }, +}; +const projectAdminAuth: AuthContextValue = { + ...creatorAuth, user: { ...creatorAuth.user!, role: "user", + capabilities: { ...creatorAuth.user!.capabilities, platformAdmin: false } }, +}; +const meta = { + component: Projects, + tags: ["ai-generated", "needs-work"], + decorators: [(Story, context) => ], + parameters: { msw: { handlers: [ + http.get("/api/v1/projects", ({ request }) => HttpResponse.json( + new URL(request.url).searchParams.get("controlPlane") === "true" ? [project] : [], + )), + ] } }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const AuthenticationDisabled: Story = { + parameters: { anonymous: true, msw: { handlers: [ + http.get("/api/v1/projects", ({ request }) => HttpResponse.json( + new URL(request.url).searchParams.has("includeDeleted") + ? [project, { ...project, _id: "deleted", id: "deleted", name: "Deleted fixture", deletedAt: "2026-01-02T00:00:00Z" }] + : [project], + )), + ] } }, + play: async ({ canvas }) => { + await expect((await canvas.findAllByRole("button", { name: "Use project" }))[0]).toBeEnabled(); + await expect(canvas.getAllByRole("button", { name: `Edit ${project.name}` })[0]).toBeEnabled(); + await expect(canvas.getAllByRole("button", { name: `Delete ${project.name}` })[0]).toBeEnabled(); + await expect(canvas.queryByRole("link", { name: "Access administration" })).not.toBeInTheDocument(); + await userEvent.click(canvas.getByRole("button", { name: "Show deleted" })); + await expect((await canvas.findAllByRole("button", { name: "Restore" }))[0]).toBeEnabled(); + await userEvent.click(canvas.getByRole("button", { name: "New project" })); + await expect(await screen.findByRole("dialog")).toBeVisible(); + await expect(screen.queryByText(/New projects grant their creator/)).not.toBeInTheDocument(); + }, +}; + +export const AdministratorWithoutMembership: Story = { + play: async ({ canvas }) => { + await expect((await canvas.findAllByText(/No accessible projects/))[0]).toBeVisible(); + await expect(canvas.queryByText(project.name)).not.toBeInTheDocument(); + await expect(canvas.getByRole("link", { name: "Access administration" })).toHaveAttribute("href", "/access"); + await expect(canvas.queryByRole("button", { name: "Use project" })).not.toBeInTheDocument(); + await expect(canvas.queryByText(/Only projects with content access appear here/)).not.toBeInTheDocument(); + await expect(canvas.queryByText("Projects you can access, with runs, profiles, criteria, and more.")).not.toBeInTheDocument(); + }, +}; + +export const ReaderWithMembership: Story = { + parameters: { reader: true, msw: { handlers: [ + http.get("/api/v1/projects", ({ request }) => HttpResponse.json( + new URL(request.url).searchParams.get("controlPlane") !== "true" ? [project] : [], + )), + ] } }, + play: async ({ canvas }) => { + await expect((await canvas.findAllByRole("button", { name: "Use project" }))[0]).toBeEnabled(); + await expect(canvas.getAllByRole("button", { name: `Edit ${project.name}` })[0]).toBeDisabled(); + await expect(canvas.getAllByRole("button", { name: `Delete ${project.name}` })[0]).toBeDisabled(); + await expect(canvas.queryByRole("link", { name: "Manage access" })).not.toBeInTheDocument(); + }, +}; + +export const CreatorProjectAdmin: Story = { + parameters: { creator: true, msw: { handlers: [ + http.get("/api/v1/projects", ({ request }) => { + if (new URL(request.url).searchParams.has("controlPlane")) return HttpResponse.json({ error: "Content inventory must not use control plane" }, { status: 400 }); + return HttpResponse.json([project, { ...project, _id: "foreign-project", id: "foreign-project", name: "Unjoined project" }]); + }), + ] } }, + play: async ({ canvas }) => { + await expect((await canvas.findAllByRole("button", { name: "Use project" }))[0]).toBeEnabled(); + await expect(canvas.getAllByRole("button", { name: `Edit ${project.name}` })[0]).toBeEnabled(); + await expect(canvas.getAllByRole("button", { name: `Delete ${project.name}` })[0]).toBeEnabled(); + await expect(canvas.getAllByRole("link", { name: "Manage access" })[0]).toHaveAttribute("href", `/access?projectId=${project._id}`); + await expect(canvas.queryByText("Unjoined project")).not.toBeInTheDocument(); + }, +}; + +export const ProjectAdminMembershipEntry: Story = { + parameters: { projectAdmin: true, msw: { handlers: [ + http.get("/api/v1/projects", () => HttpResponse.json([project])), + ] } }, + play: async ({ canvas }) => { + await expect((await canvas.findAllByRole("link", { name: "Manage access" }))[0]).toHaveAttribute("href", `/access?projectId=${project._id}`); + await expect(canvas.queryByRole("link", { name: "Access administration" })).not.toBeInTheDocument(); + await expect(canvas.getByRole("button", { name: "New project" })).toBeDisabled(); + await expect(canvas.getAllByRole("button", { name: `Edit ${project.name}` })[0]).toBeEnabled(); + await expect(canvas.getAllByRole("button", { name: `Delete ${project.name}` })[0]).toBeDisabled(); + }, +}; + +export const InventoryUnavailable: Story = { + parameters: { msw: { handlers: [ + http.get("/api/v1/projects", () => HttpResponse.json({ error: "Authority unavailable" }, { status: 503 })), + ] } }, + play: async ({ canvas }) => { + await expect(await canvas.findByRole("alert")).toHaveTextContent("Project inventory unavailable"); + await expect(canvas.queryByText(/No projects yet/)).not.toBeInTheDocument(); + }, +}; diff --git a/apps/portal/src/pages/Projects.test.tsx b/apps/portal/src/pages/Projects.test.tsx new file mode 100644 index 000000000..033633ac7 --- /dev/null +++ b/apps/portal/src/pages/Projects.test.tsx @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { composeStories } from "@storybook/react-vite"; +import { setupServer } from "msw/node"; +import { http, HttpResponse, type RequestHandler } from "msw"; +import { ProjectProvider } from "@/contexts/ProjectContext"; +import { ProjectCreateForm } from "@/components/ProjectCreateForm"; +import type { Project } from "@/types"; +import * as projects from "./Projects.stories"; + +const server = setupServer(); +const clients: QueryClient[] = []; +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterAll(() => server.close()); +afterEach(() => { + cleanup(); + clients.splice(0).forEach(client => client.clear()); + localStorage.clear(); + server.resetHandlers(); +}); + +describe("Project management RBAC", () => { + for (const [name, Story] of Object.entries(composeStories(projects))) { + it(`${name} renders and play passes`, async () => { + const parameters: { msw?: { handlers?: RequestHandler[] } } = Story.parameters; + server.use(...(parameters.msw?.handlers ?? [])); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + const { container } = render( + + + , + ); + if (!Story.play) throw new Error(`${name} is missing its required interaction test`); + await Story.play({ canvas: within(container), canvasElement: container }); + }); + } + + it("creation refreshes canonical inventories without synthesizing a client membership", async () => { + const created: Project = { + _id: "new-project", id: "new-project", name: "Created project", + createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", + }; + server.use(http.post("/api/v1/projects", () => HttpResponse.json(created, { status: 201 }))); + const client = new QueryClient(); + clients.push(client); + client.setQueryData(["projects"], []); + client.setQueryData(["access-projects"], []); + let result: Project | undefined; + render( + { result = project; }} /> + ); + fireEvent.change(screen.getByLabelText("Name *"), { target: { value: created.name } }); + fireEvent.click(screen.getByRole("button", { name: "Create project" })); + await waitFor(() => expect(result?._id).toBe(created._id)); + expect(client.getQueryData(["projects"])).toEqual([]); + expect(client.getQueryState(["projects"])?.isInvalidated).toBe(true); + expect(client.getQueryState(["access-projects"])?.isInvalidated).toBe(true); + }); + + it("keeps the creation idempotency key after an uncertain creator-membership outcome", async () => { + const keys: Array = []; + server.use(http.post("/api/v1/projects", ({ request }) => { + keys.push(request.headers.get("Idempotency-Key")); + return keys.length === 1 + ? HttpResponse.json({ operationId: keys[0], state: "pending" }, { status: 202 }) + : HttpResponse.json({ _id: "same-project", name: "Retry fixture" }, { status: 201 }); + })); + const client = new QueryClient(); + clients.push(client); + let completed = false; + render( + { completed = true; }} /> + ); + fireEvent.change(screen.getByLabelText("Name *"), { target: { value: "Retry fixture" } }); + fireEvent.click(screen.getByRole("button", { name: "Create project" })); + await waitFor(() => expect(keys).toHaveLength(1)); + await waitFor(() => expect(screen.getByRole("button", { name: "Create project" })).toHaveProperty("disabled", false)); + expect(completed).toBe(false); + fireEvent.click(screen.getByRole("button", { name: "Create project" })); + await waitFor(() => expect(completed).toBe(true)); + expect(keys[0]).toBeTruthy(); + expect(keys[1]).toBe(keys[0]); + }); +}); diff --git a/apps/portal/src/pages/Projects.tsx b/apps/portal/src/pages/Projects.tsx index f18ff5590..8ca7d4eb3 100644 --- a/apps/portal/src/pages/Projects.tsx +++ b/apps/portal/src/pages/Projects.tsx @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, Check, CircleCheck, FolderKanban, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react"; import { toast } from "sonner"; @@ -31,12 +31,12 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { DataTable, ListLayout, type DataTableColumn } from "@/components/list-layout"; import { useProjectContext } from "@/contexts/ProjectContext"; import { useSelectProject } from "@/hooks/useSelectProject"; import { api } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; import { formatDate } from "@/lib/utils"; import type { Project } from "@/types"; @@ -67,6 +67,7 @@ function EditProjectDialog({ api.updateProject(projectId(project), { name: name.trim(), description: description.trim(), + revision: project.revision ?? 0, }), onSuccess: (updated) => { queryClient.invalidateQueries({ queryKey: ["projects"] }); @@ -145,13 +146,18 @@ function EditProjectDialog({ * not wrapped in `ProjectGate` and works with no project selected. */ export function Projects() { + const { user, authEnabled } = useAuth(); + const platformAdmin = Boolean(user?.capabilities?.platformAdmin); + const canManageProjects = !authEnabled || platformAdmin; const queryClient = useQueryClient(); const navigate = useNavigate(); const { selectedProjectId } = useProjectContext(); const selectProject = useSelectProject(); const [createOpen, setCreateOpen] = useState(false); const [editing, setEditing] = useState(null); - const [showDeleted, setShowDeleted] = useState(false); + const [includeDeleted, setIncludeDeleted] = useState(false); + const canReadProject = (project: Project) => + !authEnabled || Boolean(user?.capabilities?.memberships.some(m => m.projectId === projectId(project) && m.permissions.includes("content:read"))); /** * Select a project and open its dashboard. Selecting scopes the whole portal @@ -161,24 +167,33 @@ export function Projects() { */ const openProject = (project: Project) => { // Deleted projects are not selectable — they must be restored first. - if (project.deletedAt) return; + if (project.deletedAt || !canReadProject(project)) return; const id = projectId(project); if (id !== selectedProjectId) selectProject(id); navigate("/statistics"); }; - const { data: projects = [], isLoading } = useQuery({ - queryKey: ["projects", { includeDeleted: showDeleted }], - queryFn: () => api.listProjects({ includeDeleted: showDeleted }), + const { data: projects = [], isLoading, error } = useQuery({ + queryKey: includeDeleted ? ["projects", { includeDeleted: true }] : ["projects"], + queryFn: () => api.listProjects({ includeDeleted: !authEnabled && includeDeleted }), }); - // The server already excludes soft-deleted projects unless includeDeleted is - // set, but filter defensively so toggling off never flashes a stale deleted row. const visibleProjects = useMemo( - () => (showDeleted ? projects : projects.filter((p) => !p.deletedAt)), - [projects, showDeleted], + () => projects.filter(p => (!p.deletedAt || (!authEnabled && includeDeleted)) && (!authEnabled || user?.capabilities?.memberships.some( + m => m.projectId === projectId(p) && m.permissions.includes("content:read"), + ))), + [projects, user?.capabilities?.memberships, authEnabled, includeDeleted], ); + const restoreMutation = useMutation({ + mutationFn: (project: Project) => api.restoreProject(projectId(project)), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.success("Project restored"); + }, + onError: (error) => toast.error(error instanceof Error ? error.message : "Failed to restore project"), + }); + const deleteMutation = useMutation({ mutationFn: (project: Project) => api.deleteProject(projectId(project)), onSuccess: (_data, project) => { @@ -193,17 +208,6 @@ export function Projects() { }, }); - const restoreMutation = useMutation({ - mutationFn: (project: Project) => api.restoreProject(projectId(project)), - onSuccess: (_data, project) => { - queryClient.invalidateQueries({ queryKey: ["projects"] }); - toast.success(`Project "${project.name}" restored`); - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : "Failed to restore project"); - }, - }); - const columns: DataTableColumn[] = [ { id: "name", @@ -256,26 +260,10 @@ export function Projects() { align: "right", cell: (p) => { const isActive = projectId(p) === selectedProjectId; - if (p.deletedAt) { - return ( -
e.stopPropagation()} - > - -
- ); - } + if (p.deletedAt) return ; return (
)} + {(platformAdmin || user?.capabilities.memberships.some(m => m.projectId === projectId(p) && m.role === "admin" && m.permissions.includes("members:write"))) && } @@ -322,7 +315,7 @@ export function Projects() { This soft-deletes the project and hides it (along with any runs and resources it contains) from the default lists. You can bring it back - anytime with the Show deleted toggle. + {authEnabled ? "from Access administration." : "by selecting Show deleted on this page."} @@ -346,37 +339,24 @@ export function Projects() { <> setCreateOpen(true)}> - - New project - +
+ {!authEnabled && } + {platformAdmin && } + +
} >
-
- -

- Select a project to scope the whole portal — runs, profiles, criteria, MCP - servers, and more — to it. Selecting opens the project’s{" "} - Statistics, and your active - project stays switchable from the top bar. -

-
-
- - -
- + Project inventory unavailable: {error.message} +

: projectId(p)} @@ -387,15 +367,15 @@ export function Projects() {

- No projects yet. Create one to get started. + {!authEnabled ? "No projects yet. Create your first project." : platformAdmin ? "No accessible projects. Create one or manage memberships in Access administration." : "No accessible projects. Ask a project or platform administrator for membership."}

-
} - /> + />}
diff --git a/apps/portal/src/pages/RunDetail.tsx b/apps/portal/src/pages/RunDetail.tsx index af814ad0d..101abe446 100644 --- a/apps/portal/src/pages/RunDetail.tsx +++ b/apps/portal/src/pages/RunDetail.tsx @@ -1136,7 +1136,8 @@ export function RunDetail() { diff --git a/apps/portal/src/pages/access.stories.play.test.tsx b/apps/portal/src/pages/access.stories.play.test.tsx new file mode 100644 index 000000000..00b0227e9 --- /dev/null +++ b/apps/portal/src/pages/access.stories.play.test.tsx @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @vitest-environment happy-dom +import { afterAll, afterEach, beforeAll, describe, it, vi } from "vitest"; +import { Suspense } from "react"; +import { cleanup, render, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { composeStories } from "@storybook/react-vite"; +import { setupServer } from "msw/node"; +import { http, HttpResponse, type RequestHandler } from "msw"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { ProjectProvider } from "@/contexts/ProjectContext"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import { mswHandlers } from "../../.storybook/msw-handlers"; +import * as administration from "./AccessAdministration.stories"; +import * as admin from "./Admin.stories"; +import * as projects from "./Projects.stories"; +import * as accessShell from "../components/AccessShell.stories"; +import * as agentBadge from "../components/AgentBadge.stories"; +import * as layout from "../components/Layout.stories"; + +const server = setupServer( + ...mswHandlers, + http.get("/api/v1/version", () => HttpResponse.json({ + commit: "test-commit", buildTime: "1970-01-01T00:00:00Z", authEnabled: true, + })), + http.get("/ready", () => HttpResponse.json({ + status: "ready", migrations: { ready: true, applied: [], pending: [], totalApplied: 0 }, + })), +); +const clients: QueryClient[] = []; +beforeAll(() => { + vi.stubGlobal("__GIT_COMMIT__", "test-commit"); + vi.stubGlobal("__BUILD_TIME__", "1970-01-01T00:00:00Z"); + vi.stubGlobal("__GIT_BRANCH__", "test-branch"); + server.listen({ onUnhandledRequest: "error" }); +}); +afterAll(() => server.close()); +afterEach(() => { + cleanup(); + clients.splice(0).forEach(client => client.clear()); + server.resetHandlers(); + localStorage.clear(); +}); + +const badgeStories = composeStories(agentBadge); +const layoutStories = composeStories(layout); +const stories = { + ...composeStories(administration), ...composeStories(admin), ...composeStories(accessShell), + ...Object.fromEntries(Object.entries(composeStories(projects)).map(([name, story]) => [`Projects${name}`, story])), + AgentBadgeAdmin: badgeStories.Plain, AgentBadgeMember: badgeStories.ProjectMember, + MemberNavigation: layoutStories.MemberNavigation, AdminNavigation: layoutStories.AdminWithoutMembership, +}; +describe("Access story interactions", () => { + for (const [name, Story] of Object.entries(stories)) { + it(`${name} renders and play passes`, async () => { + const parameters: { msw?: { handlers?: RequestHandler[] } } = Story.parameters; + server.use(...(parameters.msw?.handlers ?? [])); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + let navigationReady = name !== "SpecificProjectPendingOperatorGuidance"; + let navigation: Promise | undefined; + function DeferredNavigationStory() { + const { pathname, search } = useLocation(); + // Keep the scoped view visible while the all-project navigation is pending. + if (!navigationReady && pathname === "/access" && !search) { + navigation ??= new Promise(resolve => { + setTimeout(() => { navigationReady = true; resolve(); }, 50); + }); + throw navigation; + } + return ; + } + const { container } = render(); + if (!Story.play) throw new Error(`${name} is missing its required interaction test`); + await Story.play({ canvas: within(container), canvasElement: container }); + }); + } +}); diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index 5a84b7463..060aa4c05 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -1222,6 +1222,7 @@ export type RunSortDir = "asc" | "desc"; * `id` mirrors `_id`. */ export interface Project { + revision?: number; _id: string; /** Mirror of `_id` added by the API response. */ id?: string; @@ -1242,6 +1243,7 @@ export interface CreateProjectRequest { /** Body for updating a project (`PATCH /projects/:id`). */ export interface UpdateProjectRequest { + revision?: number; name?: string; description?: string; } diff --git a/apps/portal/src/vite-env.d.ts b/apps/portal/src/vite-env.d.ts index 863c96ee3..3f65878cb 100644 --- a/apps/portal/src/vite-env.d.ts +++ b/apps/portal/src/vite-env.d.ts @@ -9,9 +9,9 @@ interface ImportMetaEnv { /** Show the Pass@k metrics table on the Statistics page (default: hidden) */ readonly VITE_SHOW_PASS_AT_K?: string; /** - * Local-dev-only opt-out for the auth feature. Set to `"false"` to disable - * sign-in for `vite dev` builds only. Integration/production are controlled at - * runtime via `SCOPE_AUTH_ENABLED` (see `ScopeRuntimeConfig.authEnabled`), + * Local-dev auth/RBAC mode, coordinated with the API. Vite gives the canonical + * shell SCOPE_AUTH_ENABLED precedence over this legacy flag. Built images use + * runtime `SCOPE_AUTH_ENABLED` (see `ScopeRuntimeConfig.authEnabled`), * because the built image is promoted int→prod. Auth is ON by default. * See apps/portal/src/lib/auth/authConfig.ts. */ diff --git a/apps/portal/vite.config.ts b/apps/portal/vite.config.ts index 7d505e1ab..2cbe91d43 100644 --- a/apps/portal/vite.config.ts +++ b/apps/portal/vite.config.ts @@ -35,6 +35,9 @@ export default defineConfig({ __GIT_COMMIT__: JSON.stringify(git.commit), __BUILD_TIME__: JSON.stringify(git.buildTime), __GIT_BRANCH__: JSON.stringify(git.branch), + ...(process.env.SCOPE_AUTH_ENABLED !== undefined ? { + "import.meta.env.VITE_AUTH_ENABLED_LOCAL": JSON.stringify(process.env.SCOPE_AUTH_ENABLED), + } : {}), }, resolve: { alias: { diff --git a/apps/workers/post-processor/src/post-processor.test.ts b/apps/workers/post-processor/src/post-processor.test.ts new file mode 100644 index 000000000..45afce886 --- /dev/null +++ b/apps/workers/post-processor/src/post-processor.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { afterEach, expect, it, vi } from "vitest"; +import { PostProcessor } from "./post-processor.js"; + +vi.mock("shared", () => ({ + BaseQueueProcessor: class {}, + BlobStorage: class {}, + Retry: () => (_target: object, _key: string, _descriptor: PropertyDescriptor) => undefined, +})); +afterEach(() => vi.unstubAllGlobals()); + +it("does not call the report API after post-processing while service authorization is deferred", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const processor = new PostProcessor({ + mongoUri: "mongodb://unused", mongoDatabase: "unused", mongoCollection: "requests", + storageAccountName: "unused", storageConnectionString: "", queueName: "unused", batchSize: 1, pollIntervalMs: 1, + redisHost: "", redisPort: 6379, redisPassword: "", apiBaseUrl: "http://api.invalid", + }); + const log = vi.fn().mockResolvedValue(undefined); + const trigger: (id: string, logger: typeof log) => Promise = Reflect.get(processor, "triggerReportGeneration"); + await trigger.call(processor, "accepted-fixture", log); + expect(fetchMock).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith("info", "Report generation disabled for the project RBAC MVP"); +}); diff --git a/apps/workers/post-processor/src/post-processor.ts b/apps/workers/post-processor/src/post-processor.ts index 03b276db8..2dc266d60 100644 --- a/apps/workers/post-processor/src/post-processor.ts +++ b/apps/workers/post-processor/src/post-processor.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import type { DequeuedMessageItem } from "@azure/storage-queue"; +import { REPORT_GENERATION_ENABLED } from "shared/rbac"; import { BaseQueueProcessor, BlobStorage, Retry, type BaseQueueProcessorConfig, type LogEvent, type VisibilityHeartbeat } from "shared"; import { trackMetric, trackEvent } from "telemetry"; import { POST_PROCESSOR_VERSION } from "./version.js"; @@ -140,6 +141,10 @@ export class PostProcessor extends BaseQueueProcessor { */ @Retry({ maxRetries: 3, baseDelayMs: 1000, isRetryable: () => true }) private async triggerReportGeneration(requestId: string, log: (level: LogEvent["level"], message: string) => Promise): Promise { + if (!REPORT_GENERATION_ENABLED) { + await log("info", "Report generation disabled for the project RBAC MVP"); + return; + } if (!this.apiBaseUrl) return; const response = await fetch(`${this.apiBaseUrl}/api/v1/reports/trigger`, { diff --git a/apps/workers/report-generator/src/report-queue-processor.test.ts b/apps/workers/report-generator/src/report-queue-processor.test.ts index 16ece9146..3751e67aa 100644 --- a/apps/workers/report-generator/src/report-queue-processor.test.ts +++ b/apps/workers/report-generator/src/report-queue-processor.test.ts @@ -377,6 +377,7 @@ describe("ReportQueueProcessor – handleRequest template validation", () => { let mockUpdateOne: ReturnType; beforeEach(() => { + mockCreateSession.mockClear(); capturedEventHandler = undefined; log = vi.fn().mockResolvedValue(undefined); processor = new ReportQueueProcessor(makeConfig()); @@ -415,18 +416,21 @@ describe("ReportQueueProcessor – handleRequest template validation", () => { } as import("shared").ReportDocument; } - it("throws when report has no templateId", async () => { + it("blocks legacy queued jobs before template lookup or inference", async () => { const doc = makeReportDoc({ templateId: undefined }); - await expect( - (processor as any).handleRequest(doc, makeMessage(), "pop-1", log) - ).rejects.toThrow("has no templateId"); - - // Status should NOT be set to "generating" since template validation fails first - expect(mockUpdateOne).not.toHaveBeenCalled(); + const stop = vi.fn().mockReturnValue("pop-1"); + const handle: (doc: import("shared").ReportDocument, message: ReturnType, heartbeat: { stop: () => string }, logger: typeof log) => Promise = Reflect.get(processor, "handleRequest"); + await handle.call(processor, doc, makeMessage(), { stop }, log); + expect(mockUpdateOne).toHaveBeenCalledWith({ _id: doc._id }, { + $set: { status: "failed", error: "report_generation_disabled", updatedAt: expect.any(Date) }, + }); + expect(stop).toHaveBeenCalledOnce(); + expect(fetch).not.toHaveBeenCalled(); + expect(mockCreateSession).not.toHaveBeenCalled(); }); - it("throws when templateId points to a non-existent template", async () => { + it("blocks jobs with a template without fetching it or enqueuing replacement work", async () => { const doc = makeReportDoc({ templateId: "nonexistent-template" }); // Mock fetch to return 404 (fetchReportTemplate returns null) @@ -436,15 +440,12 @@ describe("ReportQueueProcessor – handleRequest template validation", () => { }); vi.stubGlobal("fetch", fetchMock); - await expect( - (processor as any).handleRequest(doc, makeMessage(), "pop-1", log) - ).rejects.toThrow("Report template 'nonexistent-template' not found"); - - // Template lookup must be scoped to the report's project (templates are - // project-scoped; a slug-only lookup is rejected by the API). - const requestedUrl = String(fetchMock.mock.calls[0][0]); - expect(requestedUrl).toContain("/report-templates/nonexistent-template"); - expect(requestedUrl).toContain("projectId=proj-1"); + const handle: (doc: import("shared").ReportDocument, message: ReturnType, heartbeat: { stop: () => string }, logger: typeof log) => Promise = Reflect.get(processor, "handleRequest"); + await handle.call(processor, doc, makeMessage(), { stop: () => "pop-1" }, log); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockUpdateOne).toHaveBeenCalledWith({ _id: doc._id }, expect.objectContaining({ + $set: expect.objectContaining({ status: "failed", error: "report_generation_disabled" }), + })); }); }); diff --git a/apps/workers/report-generator/src/report-queue-processor.ts b/apps/workers/report-generator/src/report-queue-processor.ts index 7ecaac8f8..13cd08a06 100644 --- a/apps/workers/report-generator/src/report-queue-processor.ts +++ b/apps/workers/report-generator/src/report-queue-processor.ts @@ -6,6 +6,7 @@ import { CopilotClient, SessionEvent } from "@github/copilot-sdk"; import { mkdirSync, rmSync, existsSync, readFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; +import { REPORT_GENERATION_ENABLED } from "shared/rbac"; import { BaseQueueProcessor, BaseQueueProcessorConfig, @@ -85,6 +86,15 @@ export class ReportQueueProcessor extends BaseQueueProcessor { heartbeat: VisibilityHeartbeat, log: (level: LogEvent["level"], msg: string, data?: Record) => Promise ): Promise { + if (!REPORT_GENERATION_ENABLED) { + await this.collection.updateOne({ _id: doc._id }, { + $set: { status: "failed", error: "report_generation_disabled", updatedAt: new Date() }, + }); + await log("warn", "Report generation disabled; queued work is blocked pending service authorization"); + const receipt = heartbeat.stop(); + await this.safeDeleteMessage(message.messageId, receipt); + return; + } const generationStart = Date.now(); const reportId = doc._id; const requestId = doc.requestId; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 50410ab84..bd1792734 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -110,6 +110,8 @@ services: # =========================================================================== portal: + # Vite compiles on demand; the production nginx CPU limit stalls cold loads. + cpus: "1.0" # dev target (Vite dev server + esbuild dep-optimization) OOMs at 512m; needs headroom above prod 128m runtime limit mem_limit: 1g build: @@ -131,17 +133,16 @@ services: 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 - # governs local dev only; integration/production are toggled at runtime via - # SCOPE_AUTH_ENABLED on their deploy overlays. - VITE_AUTH_ENABLED_LOCAL: "${VITE_AUTH_ENABLED_LOCAL:-true}" + # One setting controls API authentication/RBAC and Portal sign-in. + # The legacy local flag remains an alias when SCOPE_AUTH_ENABLED is unset. + VITE_AUTH_ENABLED_LOCAL: "${SCOPE_AUTH_ENABLED-${VITE_AUTH_ENABLED_LOCAL-true}}" depends_on: entra-local: condition: service_healthy + required: false entra-local-init: condition: service_completed_successfully + required: false develop: watch: - action: sync diff --git a/docker-compose.yml b/docker-compose.yml index 31b6b191a..7f0dfb2e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -243,7 +243,7 @@ services: - 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 + # redirect URI and API profile claims so sign-in and user labels work 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 # shared CA and entra-local certificate SAN keep TLS verification enabled. @@ -258,6 +258,29 @@ services: const uri = 'http://localhost:' + (process.env.PORTAL_PORT || '5100'); const base = 'https://entra-local:8443/admin/api/apps/' + appId + '/redirectUris'; const body = JSON.stringify({ uri, type: 'spa' }); + const configureProfileClaims = async () => { + const url = 'https://entra-local:8443/admin/api/apps/cccccccc-0000-0000-0000-000000000005'; + const check = (response) => { + if (!response.ok) throw Object.assign(new Error('Profile claim configuration HTTP ' + response.status), { + permanent: response.status >= 400 && response.status < 500 && response.status !== 429, + }); + }; + const current = await fetch(url, { signal: AbortSignal.timeout(5000) }); + check(current); + const app = await current.json(); + const optionalClaims = app.optionalClaims || { idToken: [], accessToken: [] }; + const accessToken = [...(optionalClaims.accessToken || [])]; + for (const name of ['given_name', 'family_name', 'email', 'preferred_username']) { + if (!accessToken.some(claim => claim.name === name)) accessToken.push({ name, essential: false }); + } + const updated = await fetch(url, { + method: 'PATCH', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ optionalClaims: { ...optionalClaims, accessToken } }), + signal: AbortSignal.timeout(5000), + }); + check(updated); + console.log('configured signed API profile claims'); + }; const post = () => new Promise((res, rej) => { 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 })); @@ -268,10 +291,15 @@ services: for (let i = 0; i < 60; i++) { try { const { status, body } = await post(); - if (status === 201) { console.log('registered redirect URI', uri); return; } - if (status === 409) { console.log('redirect URI already registered', uri); return; } + if (status === 201 || status === 409) { + console.log(status === 201 ? 'registered redirect URI' : 'redirect URI already registered', uri); + await configureProfileClaims(); return; + } console.log('attempt', i, 'status', status, body); - } catch (e) { console.log('attempt', i, 'waiting for entra-local:', e.code || e.message); } + } catch (e) { + if (e.permanent) { console.error(e.message); process.exit(1); } + console.log('attempt', i, 'waiting for entra-local:', e.code || e.message); + } await new Promise(r => setTimeout(r, 2000)); } console.error('failed to register redirect URI after retries'); process.exit(1); @@ -346,6 +374,7 @@ services: TOKEN_MANAGER_URL: http://token-manager:80 AZURE_STORAGE_QUEUE_REPORT: report-queue SCOPE_STRICT_AGENT_CAPABILITIES: ${SCOPE_STRICT_AGENT_CAPABILITIES:-false} + SCOPE_AUTH_ENABLED: "${SCOPE_AUTH_ENABLED-${VITE_AUTH_ENABLED_LOCAL-true}}" GITHUB_TOKEN: ${GITHUB_TOKEN:-} GITHUB_MODELS_API_KEY: ${GITHUB_MODELS_API_KEY:-${GITHUB_TOKEN}} GIT_COMMIT: ${GIT_COMMIT:-development} @@ -362,8 +391,6 @@ services: 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}" @@ -594,7 +621,7 @@ services: ports: - "${PORTAL_PORT:-5100}:80" environment: - SCOPE_AUTH_ENABLED: "${SCOPE_AUTH_ENABLED:-true}" + SCOPE_AUTH_ENABLED: "${SCOPE_AUTH_ENABLED-${VITE_AUTH_ENABLED_LOCAL-true}}" depends_on: api: condition: service_started diff --git a/docs/architecture/app-design.md b/docs/architecture/app-design.md index f1a243262..a6d91ded2 100644 --- a/docs/architecture/app-design.md +++ b/docs/architecture/app-design.md @@ -40,23 +40,35 @@ flowchart LR `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 +`POST /api/v1/users/me` path for JIT/profile/`lastLoginAt` writes. New users +receive `role: "user"`; login never changes role or disabled state. Initial admin +bootstrap and lockout repair use the operator-only exact-identity utility, not +environment allowlists or HTTP recovery routes. `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. +cached. Fixed/non-sliding identity TTL defaults to 300 seconds +(`AUTH_USER_CACHE_TTL_SECONDS`). Authorization independently rereads canonical +users/memberships/projects and operation markers, so cached identity role/disable +metadata does not preserve an authorization grant. 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. +The singular stored `user`/`admin` role controls platform administration. +Unique project memberships supply fixed reader/contributor/admin bundles; +the admin bundle includes `members:write` for roster and existing-user membership +management within that project, including bounded enrolled-user discovery by +name/email/Scope UUID through the project-authorized membership picker. Global +administrative directory endpoints remain platform-only. Platform administration does not imply +project-content access. Protected routes +fail closed, with a 20-second total authority decision/stream lease budget. +See [Authentication & RBAC](auth-rbac.md) for current local implementation status, +durable administrative operations, operator recovery and remaining release gates. +Creator ownership, service issuance and internal JWTs remain deferred. ### Benchmark entities @@ -129,10 +141,10 @@ 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** (future ownership/RBAC is specified in -[`auth-rbac.md`](auth-rbac.md); any caller admitted by the current auth rollout may -pass any `projectId`). +set at creation and never changed. Project filtering is combined with the canonical +membership authorization boundary in [`auth-rbac.md`](auth-rbac.md); a caller cannot +widen access by supplying another project's ID. Child/reference checks preserve +the authorized parent project, including bulk, artifact and stream endpoints. ### Scoped vs. unscoped entities @@ -141,7 +153,7 @@ pass any `projectId`). | **Root** (no parent) | `requests`, `profiles`, `criteria`, `prompt-features`, `mcp-servers`, `report-templates`, `skills`, `extensions`, `codebases`, `resources` | From the `?projectId=` query param at create time | | **Child** (references a parent) | `runs` (history), `profile-versions`, `codebase-revisions`, `resource-revisions`, `reports`, `insights` | Copied from the parent doc's `projectId` | | **Special** (deterministic key → per-project copies) | `task-prompts`, `skill-revisions` | From the run's `projectId`; see below | -| **Unscoped** | `projects`, `agents`, `models`, tokens/accounts, feature-flags | n/a — never filtered by project | +| **Unscoped storage** | `projects`, `agents`, `models`, tokens/accounts, feature-flags | Project inventory is membership-filtered except the platform control plane; agent/model reads require active membership; credential/config administration is platform-only | ### Resolution model (no default, fail-fast) @@ -193,6 +205,15 @@ the app layer (scoped `findOrCreate` / existence checks) — which is *why* edit first and then write by its real `_id`. See `db.md` for the index details. This invariant is recorded so future routes and entities keep obeying it; see also `criteria-provider.md`. +The same rule covers **context lookups that feed an LLM**. The AI helpers +(`POST /api/v1/task-prompts/generate`, `POST /api/v1/task-prompts/:id/extract-features`, +`POST /api/v1/criteria/generate-prompt`, `POST /api/v1/prompt-features/generate-prompt` and +`POST /api/v1/prompt-features/extract-from-text`) load existing prompts, criteria or feature +definitions as model input. Every such query filters by the authorized project: the required +`?projectId=` for collection-level helpers, or the loaded task prompt's own `projectId` for +`/:id/extract-features`. They never fall back to an unscoped query, so one project's data cannot +reach another project's LLM input or the results persisted from it. + A bounded audit brought every project-scoped catalog under this rule. Besides mcp-servers, skills, extensions, criteria, and prompt-features (whose resolvers now take a required `projectId`), **`report-templates`** by-id `GET/PUT/DELETE /:id` were switched from a global `{ id }` filter to @@ -622,7 +643,18 @@ The Portal mirrors the API's fail-fast model: it holds a **selected project** (n - **`components/ProjectSwitcher.tsx`** is the header control (beside `ThemeToggle`) that lists projects, switches the active one — invalidating all scoped queries via `hooks/useSelectProject.ts`, since query keys don't embed `projectId` — and offers inline create + a link to `/projects`. Its presentational `ProjectSwitcherView` is story/play-tested. - **`components/ProjectGate.tsx`** guards scoped routes: when no project is selected it renders a first-run pick/create screen (`ProjectFirstRunView`) instead of firing a scoped request that would 400. Point-read detail routes (resolve by `_id`) and unscoped areas (agents, models, secrets, admin, `/projects`) stay ungated. The index route `/` is served by **`components/HomeRoute.tsx`**, the unscoped **home**: on entry it clears any active project (`useSelectProject(undefined)`, which also resets scoped query caches) and renders the picker, then forwards to `/statistics` once the user picks a project. Reaching `/` by any means (the logo, a typed URL, the back button, a bookmark) therefore de-scopes; there is no default project. - **`components/Layout.tsx`** hides project-scoped sidebar entries until a project is in use: with no selection (`hasProject === false`) only the global entries render (Projects, the Platform group of Agents/Models/Secrets, and the footer), while the New Run CTA and the Activity / Library / Resources / Dev groups appear once a project is selected. This keeps the first-run sidebar from advertising links that would only hit the `ProjectGate`. Scoped-vs-global mirrors `App.tsx` (``-wrapped routes are scoped). The **Scope logo** doubles as home: it is a plain link to `/`, so clicking it lands on `HomeRoute`, which does the de-scoping — no click-handler side effect and no open-in-new-tab special-casing. -- **`pages/Projects.tsx`** (`/projects`, unscoped) manages projects themselves — create / rename / describe / soft-delete. Delete always succeeds (**204**), even for a non-empty project, because it is a reversible soft-delete. A **Show deleted** toggle lists soft-deleted projects (`GET /projects?includeDeleted=true`) and offers a **Restore** action per row (`POST /projects/:id/restore`); deleted projects are not selectable until restored. The same affordances exist in the CLI (`project list --include-deleted`, `project restore `). +- **`pages/Projects.tsx`** (`/projects`, unscoped) lists only projects where the user has membership with `content:read`, including for platform admins. It retains create / rename / describe / soft-delete within the applicable role permissions. Deleted/non-readable projects are excluded and cannot be selected. Platform admins manage all memberships and discover/restore deleted projects in **`pages/AccessAdministration.tsx`** (`/access`), using the control-plane inventory. Plain `/access` keeps the project selector; `/access?projectId=...` locks to that project with no selector and an **All project access** return link. The CLI retains equivalent inventory/restore operations (`access projects`, `project list --include-deleted`, `project restore `). + +These role restrictions apply with `SCOPE_AUTH_ENABLED=true` (the default). +Explicit off disables API auth/RBAC and Portal sign-in together for trusted +local/test deployments; project scoping/reference checks remain active. +Anonymous `/projects` lists the application inventory and supports create, +settings, delete, **Show deleted** and restore, without creating users/memberships. +`/access` and identity administration remain unavailable, and report generation +remains disabled. `AuthModeGate` checks the public API mode before mounting +account/app providers and every 10 seconds thereafter; missing/mismatched/ +unavailable mode blocks application access. Existing access data survives mode +changes. See [auth deployment mode](auth-rbac.md#deployment-mode-one-auth-and-rbac-switch). ### User disclosures diff --git a/docs/architecture/auth-rbac.md b/docs/architecture/auth-rbac.md index 12fb57c2c..aafc78f9f 100644 --- a/docs/architecture/auth-rbac.md +++ b/docs/architecture/auth-rbac.md @@ -1,5 +1,649 @@ # Authentication & RBAC +> **Implementation status:** Project-membership RBAC is implemented. The focused +> test commands and release gates below distinguish automated coverage from +> deployment acceptance. Native Mongo checks do not prove Cosmos consistency. + +## Project-membership MVP + +The current policy is project membership, **not** creator ownership, +private/shared visibility, public links, or an administrator content bypass. +The historical proposal below is retained for context and is superseded wherever +it differs from this section. + +### Deployment mode: one auth and RBAC switch + +`SCOPE_AUTH_ENABLED` controls both API authentication/authorization and Portal +sign-in. It defaults to **enabled**; only `false`, `0`, `no` or `off` explicitly +disable it (case-insensitive, trimmed). Missing/empty/invalid values stay enabled; +missing IdP configuration then fails API startup. Configure and restart API and +Portal together. Compose retains `VITE_AUTH_ENABLED_LOCAL` only as a fallback +when the canonical variable is unset; native Vite also honors the canonical shell +setting. Production Portal mode is injected at container startup. + +**Explicit off is unrestricted application access for trusted local/test +deployments**, not a synthetic principal, public sharing feature, outage fallback +or lockout-recovery path. API role/membership checks are skipped, while project +scoping/existence and parent/reference validation remain enforced. Normal content, +catalogs and project lifecycle work anonymously; project operations retain durable +intent/completion with the existing anonymous actor and no creator membership. +No user or membership is fabricated. `/users/me` returns `401`; identity and +membership administration returns `503 authentication_disabled`. The Portal hides +account/access management but permits project inventory, creation, settings, +soft-delete and deleted-project restoration. Report generation stays disabled. + +All persisted identities, roles, memberships and audit records survive mode +changes. Re-enabling restores the policy below; projects created anonymously need +explicit membership assignment. A browser flag cannot turn API enforcement off. +The Portal probes public no-store `/api/v1/version` before mounting the app and +rechecks every 10 seconds; absent, invalid, mismatched or unavailable API mode +blocks content rather than inferring anonymous access. CLI project selection uses +the same explicit API mode and retains membership checks when enabled. +See [environment setup](../../ENV_VARIABLES.md#scope_auth_enabled--combined-authentication-and-rbac-toggle) +for coordinated configuration and workers-free emulator-free startup. + +The following identity, role and freshness contract applies when auth is enabled. + +| Principal | Project content | Project settings | Memberships and project lifecycle | +|-----------|-----------------|------------------|----------------------------------| +| Enrolled user with no membership | None; own account only | None | None | +| Reader | Read, including stored reports/artifacts | None | None | +| Contributor | Read/create/update/delete all project content, regardless of creator | None | None | +| Project admin | Contributor permissions | Edit name/description | View/add/change/revoke membership of their own project; no project lifecycle powers | +| Platform admin without membership | None | Control-plane metadata | Create/delete/restore projects, manage memberships, promote enrolled users to platform admin, disable ordinary users | + +Platform admins explicitly add themselves through the same audited membership +operation to read project content, except that **new project +creation grants the verified creator an audited project-admin membership**. This +supersedes the earlier no-creator-membership decision. Other platform admins can +discover/administer every project without membership but receive no blanket +content access. Existing projects/memberships are not backfilled. Project +membership does not reveal other projects. Readers/contributors and enrolled +users without membership cannot enumerate users. +Platform admins can search existing enrolled Scope users by display name, email, +sign-in alias or Scope UUID. Project admins can use the same minimal discovery +through their own project's membership picker, including enrolled users outside +that project. Neither search queries the IdP directory, pre-provisions accounts +or sends invitations. Exact identity lookup remains available for operator/API +workflows. This supersedes the initial exact-lookup-only administration UI. +No mandatory project-admin member, ownership transfer, invitation, configurable +role, wildcard permission, or second user-security authority is introduced. + +### New decision: project admins manage their own memberships + +The user-authorized extension on 2026-10-21 supersedes the original MVP's +platform-only membership restriction. A **current, canonically verified project +admin** receives the fixed project-scoped `members:write` capability. It allows +viewing the roster, adding existing enrolled users, changing any fixed +reader/contributor/admin project role, and revoking membership in that project +only. Readers/contributors cannot list the privileged roster, look up candidates, +manage membership or promote themselves. A foreign-project membership operation +returns `404`; an insufficient role within an accessible project returns `403`. +Platform administrators retain their global membership/control-plane powers. + +This grants no platform role, global administrative endpoints, account +disable/promotion, provider Secrets, project create/delete/restore, document +ownership or internal service access. Project admins may demote/remove themselves, even if no other +project admin exists; the UI warns that management/access will be lost. Platform +administrators can repair the project through the existing audited operation. +No last-project-admin invariant is introduced. + +`GET /api/v1/projects/:id/members/lookup/:userId` accepts one exact **enrolled +Scope UUID**, not a name/email substring or IdP identity. The canonical +project-admin/platform authorization check runs before the target lookup. +It returns the existing minimal access-user summary (Scope UUID, display labels, +platform-role label, enabled state and revision), never IdP subjects/credentials. +Missing targets return `404`, disabled targets `409`; it neither enrolls nor +invites anyone. This exact lookup remains available through CLI `access find-member`. + +A subsequent user-approved decision enables name/email discovery for project +admins. `GET /api/v1/projects/:id/members/search?q=...&cursor=...&limit=...` +requires current canonical `members:write` authority in that project before +searching enrolled users. The picker accepts case-insensitive literal name, +email, sign-in alias or Scope UUID queries, capped at 100 characters. It reuses +the minimal access-user summary and stable Scope-ID cursor pagination +(20 results by default, at most 50), with a five-second database query timeout +and `Cache-Control: no-store`. Disabled users are identified but cannot be added. +This intentionally exposes minimal enrolled-user labels across projects, not +their memberships, project content or IdP identities. Reader/contributor +searches return `403`, foreign-project searches `404`; the global +`/admin/users/search` endpoint remains platform-admin-only. + +Assignments and revocations reuse durable `AdminOperations` intent, CAS/revision, +fingerprint/idempotency, completion markers and reconciliation. Audit evidence +records the verified Scope actor and distinguishes project-admin from +platform-admin membership actions. Membership changes never mutate the target's +platform role or identity. The existing canonical authority deadline and +capability refresh/revocation bound are unchanged. +Deploy the matching API/shared capability bundle and Portal together: strict +capability validation intentionally rejects mixed old/new admin permission +bundles rather than silently accepting a different authority contract. + +Focused local checks cover positive adds, all role changes, revocation, +foreign-project and reader/contributor denial, malformed inputs, disabled/missing +targets, stale revisions, self-demotion/removal, durable actor evidence and +same-key pending recovery. Signed-bearer tests use isolated random Mongo +databases/Redis namespaces; Portal stories cover name/email entry, pagination, +disabled/missing candidates, search failures, pending retries and unchanged +platform promotion. Component tests cover +open-editor demotion, account reset and fixed-project navigation. These are +automated checks; pending-operation fault injection is not a live-stack test. + +### Global Agents and Models administration + +With authentication enabled, the Portal **Agents**, **Models** and **Secrets** +management pages and their nested/direct URLs require server-resolved +`capabilities.platformAdmin`. Their navigation group is hidden for all ordinary +users, including project admins, on desktop and mobile. The separate **Admin** +navigation button follows the same platform-admin visibility rule; direct +`/admin` routes remain platform-admin-only. Resolving or unavailable +capabilities do not mount these pages or expose management links. Agent badges +retain their names, versions and metadata for ordinary users but do not link to +agent management. +Route guards use the same case-insensitive, decoded route matching as the +Portal router, so uppercase or percent-encoded path variants cannot bypass them. + +Agent/model catalog reads remain available to users with an active project +membership so run submission, profile creation, filters and existing metadata +displays continue to work. This is selection metadata access, not management +page access. Platform admins may read these global catalogs and open management +pages without selecting or joining a project, including when the selected +project is read-only. Agent/model mutations remain platform-admin-only. +CLI catalog reads use the same API authorization. These global permissions +do not grant access to project content without membership. + +The explicit authentication-disabled mode described above retains unrestricted +application access, including these management pages and agent links. + +### Global Secrets administration + +The Portal **Secrets** area (provider keys and service accounts) is global. +With authentication enabled, only platform administrators can list, preview, +create, edit, validate or delete these records through `/api/v1/keys` and +`/api/v1/accounts`. A platform admin needs neither a selected project nor any +project membership; selecting a read-only project does not restrict global +Secrets administration. Project reader, contributor and admin roles grant no +Secrets access. The Portal blocks direct Secrets URLs as well as hiding the +navigation entry; the API independently enforces the platform role. + +Global Agents, Models and Secrets routes are excluded from the generic project +`/new` and selected-project membership gates. +Existing project-content creation remains membership-gated. Key/account +records have no project ownership, and the CLI uses the same global API. +This does not change project-scoped MCP server `env`/`headers` secrets or their +runtime resolution. + +The local environment needs both Token Manager and its Lowkey Vault backing +service to create credentials. An absent/unreachable Token Manager returns +`503 token_manager_unavailable` with an actionable message, not raw +`fetch failed`. See [Token Manager](token-manager.md#administration-and-availability) +for proxy failure and TLS requirements. + +### Verified identity, authority and freshness + +The unchanged IdP bearer is verified **before** identity-cache access on every +authenticated request. `(idp, idpTenant, idpSubject)` identifies an enrolled user; +`users._id` remains its stable Scope UUID. Any tenant accepted by the configured +trusted verifier may enroll. Only `POST /api/v1/users/me` enrolls or refreshes +profiles/login timestamps; new users always receive `role: "user"`. Login never +changes role or disabled state. GET remains read-only and no-store. +Disabled identities cannot reenroll to undo a disable. + +Redis caches identity resolution, **not authorization grants**. After resolution, +`Authorization` rereads canonical `users`, `project_memberships`, `projects` and +relevant operation-completion markers. Fixed role bundles and the strict +capability response live in `shared/rbac`. An unavailable/uninitialized authority +denies protected requests. Public health/readiness/version/about endpoints retain +their explicit classification; anonymous data access is not a recovery mechanism. + +The authorization decision has a **20-second total deadline**, with individual +Mongo reads capped at the smaller of 5 seconds and the remaining original +budget. Identity, membership, operation-marker and reference reads share that +deadline; expired background work cannot start another lookup. SSE rechecks every second and stops writes when +its monotonic lease expires; a lease cannot outlive the verified token. Closed +streams cannot be revived by a late successful check. No Redis identity-cache hit +can preserve a removed membership, disabled user or deleted project grant. +Already-authorized requests may finish; accepted queued runs remain project work +after the submitter loses access. There is no distributed worker cancellation. + +Portal refreshes capabilities using GET every 10 seconds with a 5-second timeout. +Changes/failures clear cached project data; inaccessible selections are cleared. +The no-access shell exposes the user's Scope UUID and sign-out, without content +queries. Refresh/reload never repeatedly POST enrollment. + +Portal log streams use the same bearer-aware `apiClient` and account-session +cancellation as ordinary API requests, with its existing one-time `401` refresh. +The hook resets and immediately hides stale logs on project/account/attempt +changes. Only transient establishment failures have a bounded retry; once an SSE +response is accepted, a closed authorization lease or network disconnect is +surfaced for deliberate reload/replay, not automatically resumed without a server +cursor. `403`/`404` and named terminal events never reconnect or trigger login. +See [realtime-data-flow.md](../research/realtime-data-flow.md#portal-consumer) for +framing, replay and retry details. This changes no API grant/lease checks and adds +no query-token, cookie or anonymous-auth workaround. + +### Authenticated artifact downloads + +Portal archive, snapshot, HAR, ATIF and codebase-revision download controls use +`ArtifactDownloadButton` and `lib/artifact-delivery.ts`, not protected API URLs +in browser anchors. HAR/JSONL display queries use the same delivery transport. +`fetchArtifact(path, projectId, signal, range?)` calls the existing `apiClient`: +there is no separate MSAL provider, query bearer, signed URL, cookie grant or +anonymous exception. The existing centralized single `401` refresh is preserved; +`403`/`404` fail visibly without retrying authentication. Transient GET +establishment failures have at most two bounded retries; an accepted body is +never restarted. Batch POSTs are not replayed on network/5xx failures. + +Only allowlisted, same-origin Scope request-artifact and codebase-revision +routes are accepted, with explicit `projectId` and validated iteration/index/setup +parameters. Redirects and mismatching projects are rejected. Successful status +and an artifact-appropriate media type are checked before saving; an HTML login +page or JSON archive error cannot become a fake `.tar.gz`. `ArtifactError` +retains HTTP status and response headers, including `416` range metadata for +the media bridge. Filenames prefer UTF-8 `filename*`, then `filename`, then a +sanitized fallback; path separators, controls and bidi controls are removed or +replaced. Invalid UTF-8 filename encoding fails visibly. + +Run-detail, list-row and timeline downloads pin the concrete current/historical +attempt `_id` when available. Missing or still-loading selected history never +falls back to the latest attempt or its logs. Both codebase revision controls +construct the canonical revision route instead of trusting a stored blob URL. +**Batch download intentionally means the latest attempts at server processing +time:** the existing `POST /requests/archive` body remains `{ ids }`, not +per-attempt IDs. A concurrent retry may therefore change what a batch includes. + +`useArtifactSession` binds operations and query caches to project, request/attempt, +Scope user, MSAL account and an ephemeral account-signal epoch. Project/account/ +logout/attempt changes and unmount abort stale work; the global API account +signal also cancels before React rerenders. StrictMode setup replaces aborted +controllers. HAR and JSONL readers check this combined signal after parsing, +isolate caches, hide cached data when auth is not ready and expose failures in +Network, Conversation and Tool Calls rather than presenting an empty success. + +The action prevents repeated clicks while pending, shows a spinner and a cancel +control, and reports errors through the existing toast UI. Downloads consume exact +binary chunks without unpacking/re-encoding archives, then dispatch a page-local +object URL. URLs are revoked on cancellation/session cleanup, pagehide, dispatch +failure or after a 60-second browser-dispatch grace period. **Files are buffered +in browser memory before dispatch**; very large archives remain limited by +browser memory, and cancellation cannot remove a file already saved by the +browser. This is not the progressive video path. + +Video uses a client-bound streaming bridge rather than buffering the recording. +See [authenticated-media.md](authenticated-media.md) for per-range authorization, +service-worker isolation, teardown and browser/deployment requirements. + +Focused unit tests cover binary gzip integrity, filenames/types, auth refresh, +permanent/transient failures, range metadata, origins/redirects, body cancellation, +URL lifecycle, repeated clicks, cache/account/attempt changes, concrete historical +pinning and both codebase entry points. Artifact Storybook plays run in +`artifact-stories.play.test.tsx`; real signed-in downloaded-file and progressive +range acceptance remains a separate integration step, not a claim established +by mocked/unit media tests. + +### Durable changes and operator runbooks + +`AdminOperations` persists a fingerprinted intent in `admin_operations` **before** +an access mutation. Target revision/CAS, exact preconditions and retained +`appliedOperationIds` make uncertain writes and A-then-B reconciliation +distinguishable. Targets carry their last operation marker; incomplete operations +do not authorize access. `202`/pending is not completion: retry identical input +with the same ID or give that ID to an operator for inspection/repair. +Reusing an ID with different input returns conflict. The durable reconciliation +engine remains, but there are no Portal/API/Scope CLI recovery controls or routes. +There is no multi-document transaction or dedicated atomic audit-event collection. +Migration 031 verifies membership uniqueness and rejects a TTL index on retained +administrative evidence. Rollback retains memberships/evidence rather than +reopening access. + +RBAC does **not** require the separate codebase slug/ref uniqueness upgrade. +Migration 032 is removed from automatic migration discovery and readiness; +mandatory users identity uniqueness (029), membership uniqueness (031), scoped +lookups and reference/parent checks remain unchanged. Existing codebase indexes +and genuine historical migration records are retained. Legacy global constraints +can still block cross-project name reuse; absent constraints still allow +same-project duplicate races. The Cosmos-safe fix is deferred to +[#1521](https://github.com/microsoft/scope/issues/1521), not silently skipped or +recorded as applied. See the [migration release boundary](db-migrations.md#rbac-release-boundary-codebase-uniqueness-is-deferred). + +There is currently no automatic evidence pruning. Operation histories and target +operation-ID arrays/backups grow; document-size/capacity monitoring and an approved +retention/compaction policy are prerequisites for sustained production use. +Intent-store failure prevents mutation; failed completion is reported pending, +not silently successful. Completion requires an acknowledged, matched update or +an observed identical completed operation; a zero-match/unacknowledged write +cannot produce a successful response. + +Portal `/access` remains the platform-only all-project membership entry point. +Project admins reach **Manage access** on their own Projects row at +`/access?projectId=...`; this exact-project view loads only that project and its +roster, not global control-plane inventory. Global `/access`, `/admin` and other +platform management routes remain unavailable to them. +It lists current project members by name/email and role, with **Change role** +and confirmed **Remove** actions. **Add people** opens a name/email/Scope-ID +searchable user picker and a fixed-role selector; existing members and disabled +users cannot be added. Project admins use the project-authorized search endpoint, +while platform admins retain their global administration directory endpoint. +Completed revoked memberships are hidden from the current roster but retain their revisions +for safe regrant. Disabled accounts and incomplete access changes are identified +on member rows. Ordinary-user disable remains a separate, platform-admin-only confirmed account-wide +action. Uncertain changes lock further edits and retain the exact operation key +for retry; pending rows display their operation ID and operator guidance. A +revoked-but-pending row remains visible so its repair ID survives navigation. +Project creation, settings and deletion remain on **Projects**. Restoring a +deleted project is available in Access administration, where platform admins can +still discover it without content membership. +Plain `/access` provides the full control-plane project selector, independent of +the membership-only Projects page. Choosing an option leaves that selector +available at `/access`. A deep link `/access?projectId=...` instead locks the view +to that exact project, replaces the selector with its name, and offers **All +project access** to platform admins only to return to the global view. Missing/invalid project links +never fall back to another project; navigation between deep links resets the +member editor and confirmation state. +Neither global nor project-specific `/access` views render or request the +platform-admin roster. Both views omit operation recovery controls. Pending +membership changes retain same-key retry and an operation ID to give an +operator. Legacy `operationId` query parameters are ignored; there are no recovery +deep links. Project admins cannot inspect global operation evidence or use +account-wide actions. A self-removal/demotion that loses authority before +completion requires operator inspection/repair. +Capability loss unmounts the roster/editor; account changes reset editor and +confirmation state. Selected-project changes never silently retarget a fixed +membership deep link. +The roster has no separate member-filter box. User labels show the stored display +name and email/sign-in alias rather than repeated UUIDs; the Scope UUID remains +available in a hover tooltip and as a search key. + +**Platform administrators** appears at the top of `/admin`, immediately below +the **Admin** heading and before **Feature Flags**. It has no operation +inspection/recovery controls. Legacy `/admin?operationId=...` and +`/access?operationId=...` links do not trigger inspection, repair or other writes. +This section is platform-wide and works without a selected project, including +when there are no projects. It pages through users whose stored role is `admin`, +with explicit disabled-account and pending-operation labels. Neither disabled +accounts nor incomplete security operations grant authority. +It mounts only after authentication is ready and the server-resolved +platform-admin capability is present. In explicit authentication-disabled mode, +`/admin` retains Feature Flags but hides the identity section and makes no +administrator-list or user-search requests. + +**Add platform admin** searches existing enrolled users, excluding disabled +accounts, existing admins and unknown roles. Users must sign in once before they +can be selected; no IdP directory lookup, invitation or pre-provisioning occurs. +A separate confirmation explains that the grant allows platform-wide user, +membership and project-lifecycle administration, including further promotions. +It does not create a project membership or grant project-content access. +This intentionally replaces operator-only promotion with delegation by existing +platform admins. Demotion, administrator disable/re-enable and lockout recovery +remain operator-only; there is no browser removal action. + +Explicit enrollment refreshes display-only profile labels from verified token +claims. `name` takes precedence over `given_name`/`family_name`; the signed +`preferred_username`/`upn` is retained as an advisory `alias`, distinct from +`email` (which still requires `email_verified` to persist). Missing alias claims +can fall back to an advisory email label without marking it verified. These +mutable labels never identify accounts, bootstrap admins or authorize access: +the verified identity tuple and stable Scope UUID remain authoritative. +The local emulator init enables its supported given/family-name, email and +preferred-username optional claims on the seeded API registration. Existing +profiles refresh only on explicit enrollment; GET remains read-only. + +`GET /api/v1/admin/users/search?q=...&limit=20&cursor=...` is platform-admin-only, +supports literal case-insensitive substring matching, and pages by ascending +Scope UUID. `limit` is 1–50 (default 20); the response contains `items` and an +optional `nextCursor`. Only Scope UUID, display name, email/sign-in alias, platform role, +disabled state and revision are returned, never IdP subjects or credentials. +`GET /api/v1/projects/:id/members` enriches the administrative roster with those +minimal user summaries and operation-pending state, including revoked rows for +CAS/recovery. Substring search is bounded by a five-second query timeout; its +Cosmos RU/index behavior still requires deployment-specific verification. + +`GET /api/v1/admin/platform-admins?limit=20&cursor=...` is +platform-admin-only and filters roles on the server, not a page of directory +search results. It pages by ascending Scope UUID with `limit` 1–50 (default 20) +and returns `items` plus optional `nextCursor`. Each item contains the minimal +user summary, `pending`, and optional `operationId`; it never exposes the IdP +identity tuple or credentials. The response is no-store. Sorting uses the +existing `_id` index; role-filter RU cost still needs verification on Cosmos +at deployment scale. No collection, identity-uniqueness change or migration is +required by this feature. + +`POST /api/v1/admin/users/:id/promote` accepts only `{ "revision": 0 }` +(replace `0` with the observed user revision) and an `Idempotency-Key`. +Only an existing platform admin can call it. The target must already exist; +the final audited CAS requires `role: "user"` and no `disabledAt`, preventing +a concurrent disable from being overwritten. A different pending target +security operation must be completed by identical retry or operator repair first. No arbitrary role, disable flag +or identity is accepted. Stale revisions, already-admin/disabled targets and +conflicting idempotency inputs return `409`; missing users return `404`. +An identical completed replay returns its original result without undoing +a later operator demotion. Completion returns `200`; `202` means pending, +not an effective grant. The Portal retains the same operation ID for retry +or displays the ID for operator repair. Roster, directory +and membership caches refresh after changes; the promoted user's capabilities +are read canonically on their existing read-only refresh, without reenrollment. +Auth-off mode keeps both endpoints unavailable. + +Portal **Projects** (`/projects`) shows only projects with an explicit membership +granting `content:read`, including for platform administrators. It uses the normal +membership-filtered inventory, never `controlPlane=true`, and excludes deleted or +non-readable projects defensively. Platform admins use `/access` to discover all +projects and manage memberships; both platform admins and project admins have +**Manage access** links on their own readable project rows. Projects uses a +title-only header with a platform-admin-only **Access administration** button, +without an explanatory callout or subtitle. +Creation remains on Projects and grants the creator +an audited project-admin membership. The header switcher remains membership-only, +and creation never synthesizes a membership in its cache or selects it automatically. +Inventory failures are shown as errors rather than an empty-project success state. +Capabilities refresh through GET; the creator's role appears on the next refresh +(normally within 10 seconds). CLI `project create --use` confirms the current +membership with GET before selecting, so replaying a creation cannot undo a later +revocation. + +New project creation stores the creator-membership operation ID in the durable +project intent before writing either target. It completes the deterministic +membership sub-operation before completing the project operation. An interrupted +step returns the **parent** operation as pending; operator repair of that ID resumes both +steps. An incomplete project marker withholds content authorization even if the +membership target has been written. Identical retries never recreate a revoked +membership or overwrite a newer role. This is recoverable sequencing, not a +multi-document transaction. +The creation dialog retains its idempotency key after a pending/failed response; +retry identical input or ask an operator to inspect/repair the displayed operation ID before +starting a different creation. + +Equivalent CLI commands use the existing raw IdP `SCOPE_TOKEN` environment variable: + +```bash +pnpm cli access enroll --url http://localhost:3101 +pnpm cli access me --url http://localhost:3101 +pnpm cli access find-user "$USER_ID" --url http://localhost:3101 +pnpm cli access search-users "Bob" --limit 20 --url http://localhost:3101 +pnpm cli access platform-admins --limit 20 --url http://localhost:3101 +pnpm cli access promote-admin "$USER_ID" --revision "$REVISION" \ + --operation-id "$OPERATION_ID" --url http://localhost:3101 +pnpm cli access projects --url http://localhost:3101 +pnpm cli access find-member "$PROJECT_ID" "$USER_ID" --url http://localhost:3101 +pnpm cli access search-members "$PROJECT_ID" "Alice" --limit 20 --url http://localhost:3101 +pnpm cli access members "$PROJECT_ID" --url http://localhost:3101 +pnpm cli access set-member "$PROJECT_ID" "$USER_ID" --role reader \ + --revision 0 --operation-id "$OPERATION_ID" --url http://localhost:3101 +``` + +`find-member`, `search-members`, `members`, `set-member` and `revoke-member` use the same scoped +project-admin or global platform-admin API policy and unchanged raw bearer. +`find-user`, `search-users`, `projects` and platform administration remain +platform-only. Operation inspection and repair are operator-only, outside +the Scope CLI and REST API. + +Retain the displayed operation ID for an uncertain outcome and fetch current +revisions before a different change. Pending access-command operations exit 2; +project mutation commands fail with explicit pending/operator-repair guidance. There is no +new CLI interactive login, PAT, service token or token exchange. + +Initial administrator bootstrap, demotion, administrator disable/re-enable and +lockout recovery remain **operator-managed**, using an existing exact identity +and Scope UUID. Existing platform admins can add admins through the Portal/API/CLI +workflow above; an operator is still needed when no active admin remains. +First genuinely sign in to enroll the intended account. Review its existing +Scope UUID and exact `(entra, tenant, subject)` identity using trusted operator +database access; email/display name never selects a target. After +`pnpm --filter shared build && pnpm --filter api build`, run with +`AUTH_OPERATOR_MONGO_URI` supplied through secure operator configuration. +The script does not create identities or enroll accounts. Preview first: + +```bash +node apps/api/dist/auth/operator-access.js bootstrap-admin \ + --database "$DATABASE" --confirm-database "$DATABASE" \ + --idp entra --tenant "$TENANT_ID" --subject "$SUBJECT_ID" \ + --user-id "$USER_ID" --operator "$OPERATOR" --reason "$REASON" +``` + +Use the observed revision from the preview, choose a stable operation ID, and +explicitly apply the same reviewed target: + +```bash +node apps/api/dist/auth/operator-access.js bootstrap-admin \ + --database "$DATABASE" --confirm-database "$DATABASE" \ + --idp entra --tenant "$TENANT_ID" --subject "$SUBJECT_ID" \ + --user-id "$USER_ID" --revision "$REVISION" \ + --operator "$OPERATOR" --reason "$REASON" \ + --operation-id "$OPERATION_ID" --apply +``` + +`bootstrap-admin` only promotes an existing enabled ordinary user; an already +active admin is a no-op. It never enables a disabled account. To repair a +lockout, use the same identity/database/operator flags with `set-user-access` +and explicit `--role admin|user --disabled true|false`. Preview first, then add +the observed `--revision`, a new `--operation-id` and `--apply`. It preserves +memberships/data and grants no implicit project content access. Completed +operation replay returns its original result, never undoing a later demotion. +Repeated sign-in never restores a demoted admin or enables a disabled user. + +For an uncertain operation, use its **original** operation ID rather than +inventing a replacement grant: + +```bash +node apps/api/dist/auth/operator-access.js inspect-operation \ + --database "$DATABASE" --confirm-database "$DATABASE" \ + --operator "$OPERATOR" --reason "$REASON" --operation-id "$OPERATION_ID" +node apps/api/dist/auth/operator-access.js repair-operation \ + --database "$DATABASE" --confirm-database "$DATABASE" \ + --operator "$OPERATOR" --reason "$REASON" --operation-id "$OPERATION_ID" --apply +``` + +Inspection omits target backups and identity credentials. Repair without +`--apply` only inspects; apply resumes the original durable intent, retains its +actor/fingerprint/CAS/history and emits a structured operator/reason repair log +to stderr. A completed repair is repeat-safe and does not overwrite newer +changes. User mutations require revision and operation ID when applying; +every command confirms the database and operator/reason. Exit `0` means +preview/inspection/no-op/completion, `2` means pending (not an effective grant), +and `1` means failure. + +Keep authenticated operator database access and TLS verification on outside +local emulation. When authority storage is unavailable, restore it rather than +using an auth-off flag. `AUTH_BOOTSTRAP_ADMINS` and `AUTH_BOOTSTRAP_TENANTS` +are retired: remove nonempty values before upgrading an authenticated API or +startup fails. Compose no longer automatically seeds Alice; genuine enrollment +followed by this operator command is required in a fresh local database. + +Legacy projects receive **no automatic grants**. A reviewed JSON manifest includes +`database`, `reviewedBy`, `reviewedAt`, `reason`, and explicit `entries` containing +`projectId`, enrolled `userId`, fixed `role`, expected `revision`, and stable +`operationId`. Validate all parents before applying: + +```bash +node apps/api/dist/auth/legacy-memberships.js --database "$DATABASE" --manifest "$MANIFEST" +node apps/api/dist/auth/legacy-memberships.js --database "$DATABASE" --manifest "$MANIFEST" \ + --apply --confirm-reviewer "$REVIEWER" +``` + +The default is dry-run. Unmapped projects remain inaccessible to ordinary users; +platform control-plane management can repair mappings. Never map everyone into +every project. + +### Reports, internal callers and cutover + +All new report generation is disabled: manual/bulk/retrigger endpoints, automatic +coder/post-processing producers, CLI/Portal actions and the updated queue consumer. +The API returns `report_generation_disabled` without enqueueing. Migration 031 +marks pending/generating reports failed; the disabled consumer fails and removes +queued work without invoking report tools. Existing stored reports remain +project-readable. + +Deferred OBO/service issuance is not an exemption. Report-generator HTTP tools and +worker codebase/skill/resource API clients do not gain an anonymous principal. +They must satisfy the same verified identity/membership boundary or fail closed; +enabling safe service callers is separate work. DB/queue-only workers and scheduler +are trusted infrastructure, not human API callers. + +| Scope HTTP caller | Current protected-cutover behavior | +|-------------------|------------------------------------| +| Portal and CLI | Unchanged verified IdP bearer; canonical project/platform authorization | +| Worker codebase, skill, extension, resource, MCP and prompt clients in `packages/shared/src` | No automatic bearer/service identity; unsigned calls receive `401`, not a bypass or an anonymous grant | +| REST criteria/prompt-feature providers and gate prompt resolution | Same boundary when API-backed; do not claim these configured modes work without a supported credential | +| Report tools/template fetches | Consumer and generation producers disabled before these paths execute | +| `scripts/register-agent.sh` registration jobs | Existing unsigned registration POST fails `401`; keep these jobs out of protected worker rollout until registration uses an approved authenticated path | +| `scripts/acceptance-codebase-astro.sh` | Existing unsigned benchmark script is not an authenticated RBAC acceptance command; do not run it as a workaround | + +At cutover stop old report producers/consumers and old anonymous API replicas +before admitting protected traffic. Core API/Portal tests do not establish +mixed-version cutover or general authenticated worker compatibility. + +### Focused validation and release gates + +Build `shared` before tests to avoid stale workspace exports. Policy, enrollment, +operator inputs, CLI parity and Portal story interactions have dedicated suites: + +```bash +pnpm --filter shared build +pnpm exec vitest run packages/shared/src/rbac.test.ts packages/shared/src/auth apps/api/src/auth apps/api/src/routes/secrets.test.ts apps/cli/src/commands/access.test.ts apps/cli/src/commands/project.test.ts apps/cli/src/cli-commands.test.ts apps/portal/src/pages/access.stories.play.test.tsx --reporter=default +``` + +The following suites require explicitly supplied, isolated test infrastructure. +Set `ISOLATED_MONGO_URI` and `ISOLATED_REDIS_PORT` to dedicated test services. +Each suite creates random databases and cache namespaces; do not point them at +an application database or infer success from skipped suites: + +```bash +AUTH_TEST_MONGO_URI="$ISOLATED_MONGO_URI" AUTH_TEST_REDIS_PORT="$ISOLATED_REDIS_PORT" \ + pnpm exec vitest run --config vitest.integration.config.ts \ + apps/api/src/auth/auth-flow.integration.test.ts \ + apps/api/src/auth/auth-mode.integration.test.ts \ + apps/api/src/auth/admin-operations.integration.test.ts \ + apps/api/src/routes/project-membership.integration.test.ts \ + packages/db-migrations/src/membership-authority.integration.test.ts \ + packages/db-migrations/src/rbac-codebase-constraints.integration.test.ts +``` + +These checks exercise signed-bearer verification, canonical roles/memberships, +freshness leases, creator grants, project-admin search and self-removal, pending +CAS/idempotency recovery, operator-only bootstrap/repair, legacy mappings and +the actual migration CLI/readiness through 031. Portal component/story tests +cover capability changes, account reset, fixed-project navigation (including a +deferred return to the all-project selector), pending retries and platform +promotion. Story helpers wait for navigation and selector readiness before +selecting a project. They do not establish cumulative real-browser/MSAL, +worker execution, mixed-version rollout or production acceptance. + +Standalone native Mongo is **not** a replica set. Cosmos partition/index +compatibility, stale secondary/replica visibility, RU cost and a production +<=60-second total visibility guarantee require separate validation. Cosmos +authority topology/consistency, multi-replica rollout and administrator-reviewed +production legacy mappings remain release gates. Never use historical combined +environment evidence as acceptance for a newly extracted implementation. + +
+Historical pre-membership authentication design and deferred proposals (not the current RBAC contract) + +The following snapshot predates the project-membership MVP. In particular, +references to "current" anonymous behavior, ownership/private/shared policy, +wildcards, service credentials, security-audit collections and internal-token +delivery are historical proposals, not shipped guarantees. The current contract +is the Project-membership MVP section above. + > 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: @@ -13,8 +657,9 @@ no-token/auth-not-configured anonymous rollout and public endpoints remain suppo An authenticated identity that is missing or disabled in Scope is **not** anonymous. 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, +may JIT-create an ordinary user or refresh their profile/`lastLoginAt`. +It never changes role or disabled state. +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. @@ -195,9 +840,7 @@ AUTH_API_CLIENT_ID= # expected audience (pinned) 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 +# Bootstrap an already enrolled exact identity with the operator utility, not env allowlists. 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. ``` @@ -221,8 +864,9 @@ 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*. -**Current model:** the stored user's singular `role` (default `user`, eligible -bootstrap promotion to `admin`) is returned as metadata, not enforced as route RBAC. +**Current model:** the stored user's singular `role` defaults to `user`; first-admin +promotion uses the operator utility, while an active admin can promote through +the administrative API. Login never grants privileges. The permission types, overrides, and role bundles below are **deferred design**, not the current request/response contract. @@ -329,7 +973,7 @@ 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) +ordering is preserved: **the upsert (including profile/timestamp 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. @@ -337,18 +981,17 @@ therefore update those fields before returning `403`; it never admits/caches tha `email`/`preferred_username` is mutable and not guaranteed verified, and in multi-tenant mode any tenant can sign users in): -- 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. +- Explicit login always inserts an ordinary user. An operator promotes an + already enrolled `(idp, idpTenant, idpSubject)` plus exact Scope UUID using the + database-confirmed, revision-checked utility. Email is **never** the match key. +- Bootstrap does not require `email` or `email_verified`; genuine Entra workforce + and local-emulator enrollment both work 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 administrative change), so a bad ConfigMap edit can't quietly strip admins. - Admin mutation endpoints and the durable security audit (§F) remain deferred. +- **Login never promotes or demotes.** Removing retired configuration does not + change existing roles. Demotion/disable/re-enable require an explicit operator + change; login cannot undo it. Durable intent/completion records retain the + operator, reason and prior-target backup. Index: database-enforced unique compound `(idp, idpTenant, idpSubject)`, named `uniq_identity`. Both backends have a non-unique `email` index for advisory @@ -389,7 +1032,7 @@ 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. | +| `POST /api/v1/users/me` | Explicit ordinary-user enrollment/profile/timestamp writes, then access validation and cache warming. Never changes role/disabled state. 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. | @@ -937,15 +1580,16 @@ The remaining interactive CLI design is **deferred**: 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 +### 9. SSE / log streaming transport -`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 -send the **normal `Authorization: Bearer` header**, identical to every other request. A -query-param token (`?access_token=`) is a **fallback only** (for clients that genuinely -cannot use fetch-streaming) and, when used, the token **must be short-lived and narrowly -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. +Native browser `EventSource` cannot set custom headers. The Portal now uses +**`fetch`-based streaming (`ReadableStream`) through `apiClient`** to send the normal +`Authorization: Bearer` header, identical to other requests. It does not implement +query-param tokens. The SSE endpoint applies the same project-read access check +on the parent run/report and maintains the bounded canonical authorization lease +described above. The older owner/private wording and CLI migration checklist below +are design history, not a claim that a query-token fallback or internal service +identity has been implemented. ### 10. Deferred RBAC/internal-auth secret storage @@ -1002,9 +1646,9 @@ verified separately when those deferred credentials are introduced. 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 + `UserAccessResolver`. Only POST `/users/me` enrolls/refreshes; every + GET is read-only. Bootstrap uses the explicit operator utility with an exact + existing identity/UUID; 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 @@ -1240,7 +1884,7 @@ Auth & RBAC rollout │ │ Current slice: explicit enrollment + active-user resolution; no route RBAC. │ ├── API authn middleware (verify token → req.user) [3] │ │ • verify every configured non-public bearer; anonymous rollout retained -│ │ • POST /users/me ONLY: JIT/profile/lastLoginAt/bootstrap +│ │ • POST /users/me ONLY: ordinary-user JIT/profile/lastLoginAt │ │ • 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] @@ -1324,7 +1968,7 @@ It does not replace the Portal callback/order checks or live IdP/JWKS outage tes | 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. | +| First enrollment | Scope UUID/default `user` role with no automatic admin; verified profile handling; 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. | @@ -1350,12 +1994,12 @@ It does not replace the Portal callback/order checks or live IdP/JWKS outage tes roles/permissions are managed in Scope. Tenant filtering (if any) is set on the registration. - Env: `AUTH_PROVIDER=entra`, `AUTH_AUTHORITY`, `AUTH_API_CLIENT_ID`, `AUTH_SCOPES`, - `AUTH_BOOTSTRAP_ADMINS=entra:/`, `AUTH_BOOTSTRAP_TENANTS=`, `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). +- Two genuine test identities: enroll both as ordinary users, then explicitly + promote the intended admin through the operator utility using its exact + identity/Scope UUID. Leave the second user unprivileged. ### Deferred RBAC/CLI/internal-auth scenarios @@ -1477,7 +2121,7 @@ credential or cache consistency contract. | IdP abstraction boundary | Verify-only vs full OAuth orchestration in API | **Verify-only `AuthProvider`** | API only validates bearer tokens; clients own the interactive flow. Simplest, most portable. | | Client config delivery | Hardcode per build vs API-served | **Hardcoded in CLI + Portal** | One IdP for now; a config endpoint is unauthenticated attack surface and an indirection we don't need yet. Revisit if/when multiple IdP targets exist. | | Ownership leak on foreign resource | `403` vs `404` | **`404`** | Avoids leaking existence of other users' runs. | -| New-user default role | `user` vs `admin` | **`user`** (admins via bootstrap/admin API) | Least privilege. | +| New-user default role | `user` vs `admin` | **`user`** (admins via operator bootstrap/admin API, never login) | Least privilege. | | `ownerId` identity | IdP subject (`oid`/`sub`) vs Scope-owned `users._id` | **Scope User ID (`users._id`)** | Survives IdP migration; keeps run docs IdP-agnostic; `req.user.id` is this id. | | Ownership & visibility model | Per-user silos vs owner + shareable | **Two visibility levels: `private` (owner-only) and `shared` (globally discoverable + usable, read-only unless owner)**; `ownerId` derived from `req.user.id` on create (**never** from the request body); deep links grant read-only access without ownership | Matches "find/edit what you own; read what's shared with you"; create-time owner derivation removes a spoofing vector. | | Future sharing/groups | Generalize `ownerId` now vs reserve fields + single chokepoint | **`ownerId` + `visibility` in v1; reserve `ownerType`/`groupId`/`sharedWith`; scope via one `readScope`/`writeScope` resolver** | Additive later (groups, per-user shares) with no query rewrites or data re-modelling. | @@ -1485,8 +2129,8 @@ credential or cache consistency contract. | Service auth | mTLS, Entra client-credentials, shared key | **Per-service identities** (Question E): each service gets its own credential — `INTERNAL_API_KEY_` or a per-service signed JWT — and a **narrow, least-privilege** permission set (**never** blanket `scope/*:admin`). Entra client-credentials **not required**. | No single global key whose leak grants everything; per-service perms + per-service rotation; works in any self-hosted/open-source env. | | 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)`, 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. | +| Entra tenancy | Single-tenant vs multi-tenant | **Multi-tenant** (Question D): no `tid` pinning in business logic; sign-in tenant filtering stays at the App Registration | The unique identity index is **`(idp, idpTenant, idpSubject)`**; operator promotion confirms the exact enrolled tenant/subject/UUID. | +| Bootstrap-admin matching | Login allowlist vs explicit operator command | **Explicit operator bootstrap of an existing exact identity/Scope UUID; default dry run and revision-checked apply** | No mutable email matching, first-user-wins, automatic re-promotion or environment seeding. Login preserves role/disabled state. | | 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. | @@ -1572,16 +2216,16 @@ v1 implements `private`/`shared` + deep links and reserves the remaining optiona ### 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 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. Future admin user-management -endpoints (`scope/user:admin`) must invalidate the corresponding cache entry. +**Decision** *(confirmed)*: the operator-only `bootstrap-admin` command replaces +login-time environment seeding. Genuine enrollment must exist first; the operator +confirms database, exact `(idp, idpTenant, idpSubject)`, Scope UUID, revision, +operator/reason and operation ID before applying. Default dry run writes nothing. +An existing enabled admin is a no-op; bootstrap never re-enables a disabled user. +Explicit `set-user-access` repairs admin lockouts, and `inspect-operation` / +`repair-operation` handle uncertain durable writes without public recovery routes. +Login never promotes, demotes or enables accounts. Both retired bootstrap settings +must be removed before an authenticated API upgrade. We do **not** use Entra +App Roles; canonical authorization still rereads role/disabled state. ### D. Multi-tenant Entra — **decided** @@ -1602,8 +2246,8 @@ Implications: globally unique, and guest/B2B users carry their **home-tenant** `oid`. Including `tid` in the key is therefore required: two different users in two tenants (or the same guest seen through two tenants) must not collide. The unique index is the three-part tuple. -- No code change is needed to add/remove tenants — it's an App Registration setting (plus - the `AUTH_BOOTSTRAP_TENANTS` allowlist for admin promotion). +- No code change is needed to add/remove sign-in tenants; it is an App Registration + setting. Operator bootstrap always confirms the exact existing tenant/subject. ### E. Service-to-service mechanism — **decided, implementation deferred** @@ -1822,9 +2466,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 the verified `(idp, idpTenant, idpSubject)`, - is restricted to `AUTH_BOOTSTRAP_TENANTS`, and is **promote-only**. - Neither email nor its verification flag influences promotion. + **Resolved**: bootstrap is an explicit operator command for an already enrolled + exact `(idp, idpTenant, idpSubject)` plus Scope UUID, using a reviewed revision + and durable intent. Neither login, email nor its verification flag grants admin. 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. @@ -1846,3 +2490,5 @@ the query-param fallback) given the proxy timeouts in **Resolved**: a deep link is a signed, revocable, read-only capability to one resource; it satisfies `readScope` for that resource only and **never** `writeScope`, so a recipient can view but never edit, and revoking the link removes view access. + +
diff --git a/docs/architecture/authenticated-media.md b/docs/architecture/authenticated-media.md index 70108c501..366c56915 100644 --- a/docs/architecture/authenticated-media.md +++ b/docs/architecture/authenticated-media.md @@ -8,8 +8,8 @@ but sacrifice progressive loading and seeking. A narrowly routed, same-origin service worker bridges native media requests to the originating page. The page fetches each range through `fetchArtifact` and the existing `apiClient`. It does not acquire its own tokens or introduce signed -URLs, cookie sessions, public blobs or an authentication exception. It works with -the existing explicit-login IdP bearer flow; full RBAC is not a dependency. +URLs, cookie sessions, public blobs or an authentication exception. The bridge +uses the API's current authentication and project-membership policy. ## Request and trust boundaries @@ -23,7 +23,7 @@ sequenceDiagram Worker->>Worker: Check originating WindowClient.id Worker->>Page: Per-request MessagePort, Range only Page->>API: Fixed request/attempt/project URL, bearer, Range - API->>API: Existing identity and scoped resource resolution + API->>API: Verify identity, current membership and scoped resource API-->>Page: 200/206, exact byte headers, streaming body Page-->>Worker: Status and allowlisted headers Worker-->>Video: Streaming response, no-store @@ -43,9 +43,11 @@ Other tabs, frames and clientless navigations fail closed. Tokens remain in the existing page transport. The worker receives no token, user profile or protected URL. Neither bridge writes protected bytes or handles to CacheStorage, IndexedDB or local storage. Every new range goes through the -normal API authentication and project-scoped resource lookup. This layer does -not add membership checks, revocation leases or change anonymous rollout rules. -Bytes already buffered in the browser cannot be clawed back. +normal API authentication and project-scoped resource lookup. With authentication +enabled, removing membership prevents the next authorized range. An already +authorized range may finish until its session is canceled; bytes already buffered +in the browser cannot be clawed back. See [Authentication & RBAC](auth-rbac.md) +for the canonical authority and explicit authentication opt-out contract. ## Byte delivery and failures @@ -160,9 +162,9 @@ Historical acceptance on a combined branch does not establish this extracted layer's browser acceptance, other browser engines, prefix deployments or worker-side recording/upload. -### Extracted-layer acceptance +### Delivery-only extracted-layer acceptance -The extracted implementation was exercised in Chromium 156.0.8078.12 against +The delivery-only implementation was exercised in Chromium 156.0.8078.12 against the actual Vite Portal and built API, with separate MongoDB, Redis, Azurite and Entra Local fixtures. Login used the real MSAL redirect and existing Scope-user enrollment, without RBAC changes or mocked authentication. Recordings were @@ -190,5 +192,6 @@ This does not establish production-nginx, prefix-hosted, Safari or Firefox acceptance, worker restart/idle-expiry recovery, real-worker capture/upload or every interrupted-body and delayed-history browser case. Protocol, component and API tests cover additional failure/lifecycle cases; they are not substitutes -for those remaining native-browser/deployment checks. Existing no-token rollout -behavior is unchanged, not converted into RBAC by these delivery checks. +for those remaining native-browser/deployment checks. The delivery-only checks +retained the previous no-token rollout; they do not establish cumulative RBAC +acceptance or weaken the authentication-enabled policy described above. diff --git a/docs/architecture/codebases.md b/docs/architecture/codebases.md index c8355a878..f27a3dbec 100644 --- a/docs/architecture/codebases.md +++ b/docs/architecture/codebases.md @@ -58,7 +58,8 @@ erDiagram CODEBASE { string _id "fresh UUID" - string slug "unique URL-safe identifier" + string projectId "immutable project scope" + string slug "project-local lookup key" string name "display name" string sourceType "git | archive" string source "git only: owner/repo" @@ -69,6 +70,7 @@ erDiagram CODEBASE_REVISION { string _id "fresh UUID" + string projectId "copied from parent" string codebaseId "FK to codebases" string slug "denormalized parent slug" number revisionNumber "1,2,3..." @@ -78,7 +80,7 @@ erDiagram } ``` -- **Codebase** — Mutable metadata in the `codebases` collection. It has a fresh UUID `_id`, a unique `slug`, display `name`, optional `description`, `sourceType`, Git-only `source` (`owner/repo`) and `defaultBranch`, `revisionCounter`, `latestRevisionId`, optional `creator`, timestamps, and optional `deletedAt` for soft deletion. +- **Codebase** — Mutable metadata in the `codebases` collection. It has a fresh UUID `_id`, immutable `projectId`, a project-local `slug`, display `name`, optional `description`, `sourceType`, Git-only `source` (`owner/repo`) and `defaultBranch`, `revisionCounter`, `latestRevisionId`, optional `creator`, timestamps, and optional `deletedAt` for soft deletion. - **CodebaseRevision** — Immutable snapshot in the `codebase-revisions` collection. It has a fresh UUID `_id`, `codebaseId`, denormalized `slug`, sequential `revisionNumber`, canonical `ref`, `sourceType`, provenance fields, `archiveUrl`, optional size/file metadata, optional `creator`, `resolvedAt`, and `createdAt`. - **Request.codebaseRevisionId** — Optional foreign key to `CodebaseRevisionDocument._id`. When present, workers seed the run workspace from that revision before the agent starts. @@ -98,13 +100,30 @@ Codebase revisions can be addressed in three forms: `parseCodebaseRevisionRef()` accepts `{slug}@r{N}` and bare `{slug}`. Inputs containing `@` but not the `@r{N}` shape are invalid. +Slug/ref resolution always includes the authorized project. A raw UUID still +requires matching project membership and consistent revision/parent project IDs; +it is not an access capability. Those authorization/reference checks do not depend +on changing the database's codebase uniqueness policy. + +The `(projectId, slug)` / `(projectId, ref)` unique-index upgrade is deferred to +[#1521](https://github.com/microsoft/scope/issues/1521), not an RBAC migration or +readiness prerequisite. The proposed migration 032 is absent from automatic +discovery. Existing global or project-local unique indexes are preserved, as are +authentic historical 032 ledger entries on databases that already applied it. +Different projects can reuse the same slug/ref only when existing constraints +permit it; legacy global unique indexes still prevent this. If no applicable +unique index exists, concurrent same-project creates can produce duplicate +slugs/refs despite application prechecks. RBAC does not repair or certify that +pre-existing schema condition. Native Mongo tests do not establish Cosmos +compatibility. See the [migration release boundary](db-migrations.md#rbac-release-boundary-codebase-uniqueness-is-deferred). + ## Lifecycle ### 1. Registration Codebases are created through `POST /api/v1/codebases`. Git codebases are created from JSON metadata: they require a `source` in `owner/repo` form and may include a `defaultBranch` hint. After persisting the codebase, the API **best-effort resolves the latest revision** in the same request, so a new git codebase starts with a usable snapshot (`firstRevision` is included in the response). Unlike the archive path, a resolve failure (bad repo, network, GitHub rate limit) does **not** fail creation — a git codebase is valid with zero revisions, so the API logs a warning and returns the created codebase without a revision; the user can resolve manually later. Archive codebases are created **atomically with their first revision**: the create request must be `multipart/form-data` and include the `archive` file alongside the metadata fields. The API creates the codebase and then its first revision in the same request; if the archive is missing or fails to materialize into a revision, the just-created codebase is hard-deleted (rollback) and a `400` is returned. This guarantees an archive codebase never exists without at least one revision. Subsequent revisions are added through `POST /api/v1/codebases/:id/upload`. -The codebase store derives a URL-safe slug from `slug` or `name`, ensures uniqueness by appending `-2`, `-3`, and so on, and initializes `revisionCounter` to `0`. Soft-deleted codebases still reserve their slugs so historical refs remain stable. The rollback path uses `CodebaseStore.hardDelete()` instead of a soft delete, so a codebase that failed its atomic create leaves no reserved slug behind. +The codebase store derives a URL-safe slug from `slug` or `name`, checks for existing slugs within the project and appends `-2`, `-3`, and so on, and initializes `revisionCounter` to `0`. It retries recognized duplicate-slug errors when a database constraint arbitrates concurrent inserts; prechecks alone are not race-safe when that constraint is absent. Soft-deleted codebases still reserve their slugs in these checks so historical refs remain stable. The rollback path uses `CodebaseStore.hardDelete()` instead of a soft delete, so a codebase that failed its atomic create leaves no reserved slug behind. ### 2. Resolution @@ -142,7 +161,11 @@ The Portal's codebase-revision download controls use the shared authenticated artifact transport, not raw links or the stored blob origin. They pin the revision UUID and selected project, carry the existing IdP bearer and cancel on account/project changes. See [artifact delivery](../research/realtime-data-flow.md#authenticated-artifact-delivery). -Worker seeding is unchanged. + +With membership RBAC enforced, these HTTP reads require authorized credentials. +Service/OBO issuance is deferred: workers without a suitable bearer fail closed, +not through an anonymous exemption. Native Mongo and controller tests do not +validate real authenticated worker execution; see [Authentication & RBAC](auth-rbac.md). When a worker dequeues a request, `QueueProcessor` runs setup first so the worker-specific `workspacePath` is available. If `RequestDocument.codebaseRevisionId` is set, it then: diff --git a/docs/architecture/db-migrations.md b/docs/architecture/db-migrations.md index 8195a2ac5..d97b27b2c 100644 --- a/docs/architecture/db-migrations.md +++ b/docs/architecture/db-migrations.md @@ -7,7 +7,7 @@ MongoDB migration framework powered by [mongo-migrate-ts](https://github.com/myc Migrations live in `packages/db-migrations/`. Each migration is a TypeScript file that exports a named class implementing the `MigrationInterface` from `mongo-migrate-ts`. Migrations are: - **Ordered** — discovered alphabetically by filename, applied in that order -- **Tracked** — a `_migrations` collection records which migrations have been applied (stores `className` and `timestamp`) +- **Tracked** — a `_migrations` collection records which migrations have been applied (stores `file`, `className`, and `timestamp`) - **Reversible** — each migration implements both `up()` and `down()` - **Idempotent** — designed to be safe to re-run (upserts, `$setOnInsert`, guards) @@ -78,7 +78,7 @@ Defaults match the `docker-compose.yml` local dev environment, so no configurati - **RU-paced backfill** — use `batchUpdate(col, filter, update, label)` from `../batch-update.js` (collects `_id`s, then `updateMany({_id:{$in:batch}}, update)` in small batches with 429 retry). Static update only. - **Per-document backfill referencing `_id`** — when the new value derives from each doc's own `_id` (e.g. `keyId = _id`), `batchUpdate` can't express it. Use a paced `bulkWrite` loop of `updateOne` ops instead (mirror `025-create-projects`), reusing `BATCH_SIZE`/`sleep`/`getRetryAfterMs` for pacing and 429 handling. - **Idempotent index create** — wrap `createIndex` in try/catch and tolerate "already exists" codes (`85`/`86`/`68`); rethrow real failures so validation surfaces them. -- **Unique-index swap** — before creating a composite **unique** index on a non-empty collection, `$group`/`$match` to assert no duplicate keys exist first (a pre-existing collision fails index creation on Cosmos). Drop the old index tolerating "not found" (`26`/`27`). `down()` for index ops is log-only by convention. +- **Unique-index changes** — first verify backend support and existing index/data compatibility. Native MongoDB can support an in-place swap after duplicate detection, but Cosmos MongoDB Continuous-backup accounts require unique indexes at collection creation and do not support adding or dropping them later. Do not apply a native index-swap recipe to those accounts. `down()` for index ops is log-only by convention. ## How It Works @@ -96,6 +96,9 @@ When you run a command: 3. `status` — prints a table showing applied (`up`) and pending migrations 4. `new` — scaffolds a new migration file in the migrations directory +All commands, including `status`, first attempt to create the migration-ledger +lookup indexes. The CLI is **not read-only inventory**. + ## Existing Migrations | Migration | Description | @@ -114,6 +117,7 @@ When you run a command: | `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) | +| `031-create-project-memberships` | Verifies mandatory project/user membership uniqueness, creates retained administrative-operation lookup indexes without TTL, and disables pending report generation without granting legacy access | ### Migration 029: users identity uniqueness @@ -158,6 +162,40 @@ exercise actual uniqueness, concurrency, reruns, and non-destructive failures. required.** See the [shared infrastructure guide](../shared-dev-infra.md) for selecting a real account; use isolated test data for acceptance checks. +### RBAC release boundary: codebase uniqueness is deferred + +RBAC requires migrations through **031**, including mandatory users identity +uniqueness (029) and project/user membership uniqueness (031). Neither requirement +has a non-unique fallback. + +The proposed `032-scope-codebase-uniqueness.ts` has been removed from both +`src/migrations/` and `REQUIRED_MIGRATIONS`. It is not merely optional readiness: +the automatic CLI and Compose/Kubernetes migration images no longer discover or +execute its codebase index swap. The separate, Cosmos-safe project-local slug/ref +schema upgrade is tracked in [#1521](https://github.com/microsoft/scope/issues/1521). +Do not restore the old migration as a workaround or reuse its historical filename. + +This release **does not change existing codebase indexes**. A database with +legacy global slug/ref uniqueness keeps it, including its cross-project naming +restriction. A database with project-local unique indexes keeps those instead. +If uniqueness is absent, RBAC does not install it: application check-then-insert +slug allocation is not a concurrent database uniqueness guarantee. Project-scoped +authorization and parent/reference validation remain enforced independently. +See [codebase addressing](codebases.md#revision-addressing). + +Some native-Mongo test databases already applied 032. Retain their genuine ledger +entry and installed indexes; do not delete/forge history or undo constraints to +make them resemble a fresh database. Readiness accepts extra historical entries +and still requires every active migration. Do not use `migrate:down` to undo the +removed migration; recover by a reviewed compatible application rollout, without +reopening anonymous access. + +Regression coverage runs the real CLI on a fresh isolated native database through +031 and reruns it, checks readiness with and without historical 032, preserves +existing constraints, and exercises concurrent slug/ref inserts with legacy, +project-local, and absent unique indexes. These checks do not establish live +Cosmos index compatibility or cross-replica authorization freshness. + ## CI/CD The `db-migrations` package is compiled as part of the CI `build` job (`pnpm -r build` → `tsc`). This catches TypeScript errors in migrations before deployment. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 1858dfba2..2487733de 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -88,22 +88,36 @@ flowchart TB 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. +Only `POST /api/v1/users/me` performs ordinary-user enrollment and profile/`lastLoginAt` +writes. Login never promotes or re-enables an account; first-admin bootstrap and +lockout/operation repair are explicit operator commands. 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 API response—not MSAL claims—owns the Scope identity/role/capabilities. Enrolled +CLI bearers remain compatible; new identities must explicitly enroll. Fixed +reader/contributor/admin project memberships govern content independently from +platform administration. Platform administrators have no project-content bypass; +project creation grants the creator an audited project-admin membership. +Project admins manage memberships only in their own projects, including bounded +enrolled-user discovery. Ownership, interactive CLI login and internal service +tokens remain deferred. 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. +stores tokens. It is not authorization authority: protected routes and capabilities +reread canonical users, memberships and durable operation completion. Log streams +revalidate with bounded, token-capped leases; the Portal refreshes capabilities and +clears stale queries. Unavailable authority fails closed, not anonymous. + +`SCOPE_AUTH_ENABLED` defaults on and controls API enforcement and Portal sign-in +together. Only an explicit opt-out permits unrestricted trusted local/test access; +project scoping remains enforced and identity administration stays unavailable. +New report generation is disabled across producers, API, CLI, Portal and consumer +until service authorization is available; stored reports remain project-readable. +See [Authentication & RBAC](auth-rbac.md) for the current policy, operator procedures, +failure behavior and production release gates. ### Benchmark execution diff --git a/docs/architecture/post-processing.md b/docs/architecture/post-processing.md index 9774769e4..ed82f0b8c 100644 --- a/docs/architecture/post-processing.md +++ b/docs/architecture/post-processing.md @@ -33,7 +33,12 @@ Two paths trigger post-processing: - Updates `run.turns.$.atifUrl` in the request document 4. On success, stamps `run.postProcessorVersion` and sets `run.postProcessorStatus: "done"` -5. Triggers report generation via the API (best-effort, with retry) +5. Does **not** trigger report generation while the project-membership RBAC MVP's + `REPORT_GENERATION_ENABLED` constant is false. Coder completion and + post-processing make no report-trigger HTTP call; the API also rejects every + new generation path without enqueueing. Existing ATIF/stored reports remain + project-readable. Safe service/OBO issuance is deferred, not an anonymous API + exemption; see [Authentication & RBAC](auth-rbac.md#reports-internal-callers-and-cutover). ### Blob Storage Layout diff --git a/docs/architecture/token-manager.md b/docs/architecture/token-manager.md index 544d09667..282b6b462 100644 --- a/docs/architecture/token-manager.md +++ b/docs/architecture/token-manager.md @@ -45,6 +45,35 @@ graph TB RoundRobin --> Store ``` +## Administration and availability + +Provider keys and service accounts in Portal **Secrets** are platform-global, +not project settings. With authentication enabled, the Scope API requires a +platform administrator for every `/api/v1/keys` and `/api/v1/accounts` +management operation, including reads, previews and validation. The Portal +applies the same restriction to direct routes and navigation. No project +selection or membership is required for an administrator, and a project-admin +role does not grant global credential administration. The CLI's `scope secret` +commands use this same API without a project argument. + +The API registers these protected routes even when `TOKEN_MANAGER_URL` is +missing. After authorization, missing configuration or a failed upstream +connection/body/30-second deadline or upstream `5xx` returns `503` with code +`token_manager_unavailable` and an actionable message. Responses are no-store; +transport errors and request bodies are not echoed or logged. Upstream HTTP +validation errors retain their status. The proxy does not replay credential +mutations: after an uncertain timeout, check existing metadata before retrying +a create. Read retry policy remains with the caller. + +Local Secrets functionality requires the supported `token-manager` and +`lowkey-vault` Compose services, not only API/Portal/Mongo. Use the isolated +local database and a persistent emulator volume. Keep Token Manager on the +trusted service network (any local diagnostic port must be loopback-only). +Trust the local CA in the client and provide a matching Lowkey server +certificate rather than disabling TLS verification. Authentication and API +project isolation must remain enabled during RBAC acceptance. MCP server +secrets below remain a separate project-scoped subsystem. + ## Capability-Based Model The Token Manager uses a **capability-based model** where tokens are associated with the features they enable, rather than being tied to specific worker types. This allows flexible token reuse across services. diff --git a/docs/research/realtime-data-flow.md b/docs/research/realtime-data-flow.md index 727e769d0..9b3018da1 100644 --- a/docs/research/realtime-data-flow.md +++ b/docs/research/realtime-data-flow.md @@ -18,9 +18,8 @@ GET /api/v1/reports/:id/logs?fromStart=true Defined in `apps/api/src/routes/requests/logs.ts` and `routes/reports.ts`. `fromStart=true` replays append-blob logs before switching to live streaming; terminal runs/reports replay and then send `done`. Historical attempts use the -per-run URL. These endpoints use the existing IdP bearer verification, -active-user lookup and project-scoped resource resolution. Delivery does not -introduce membership authorization or change the existing anonymous rollout. +per-run URL. These endpoints use the same verified bearer and canonical +project authorization as other protected reads. ### Server-Side Flow @@ -59,6 +58,9 @@ sequenceDiagram - **Redis pub/sub** is the primary log transport (`logs:`); a worker log with `data.final === true` also sends `done` and closes the stream. - **MongoDB Change Stream** is a **completion detector only** — watches for run status `done` or report status `completed`/`failed` +- **Authorization lease** rechecks canonical authority every second and closes a + denied/unverifiable stream; its deadline is at most 20 seconds and cannot + outlive the verified token. A late authority response cannot revive it. - Cleanup on client disconnect (`req.on("close")`) ### Portal Consumer @@ -87,7 +89,7 @@ statuses do not enter that transient retry loop; `401` remains owned by the common transport. There are **no SSE event IDs or supported resume cursors**. Once a stream is -accepted, unexpected EOF or network +accepted, EOF (including lease expiry/revocation/authority outage) or network failure disconnects with an explicit error; it never silently reconnects and duplicates replay or drops live logs. Reload the page to deliberately replay stored logs. Server `done`/`timeout`/`error` events never reconnect. diff --git a/packages/db-migrations/src/check-migrations.test.ts b/packages/db-migrations/src/check-migrations.test.ts index 56e238ece..e7eb9c31f 100644 --- a/packages/db-migrations/src/check-migrations.test.ts +++ b/packages/db-migrations/src/check-migrations.test.ts @@ -7,6 +7,7 @@ import { resetMigrationCheckCache, type MigrationCheckResult, } from "./check-migrations.js"; +import { REQUIRED_MIGRATIONS } from "./required-migrations.js"; // Mock Db function makeMockDb(appliedFiles: string[]) { @@ -29,6 +30,26 @@ describe("checkMigrations", () => { resetMigrationCheckCache(); }); + it("is ready without 032 and does not rewrite previously applied 032 history", async () => { + const current = await checkMigrations(makeMockDb([...REQUIRED_MIGRATIONS])); + expect(current).toMatchObject({ ready: true, pending: [], totalApplied: 31 }); + + resetMigrationCheckCache(); + const historicalDb = makeMockDb([...REQUIRED_MIGRATIONS, "032-scope-codebase-uniqueness.ts"]); + const historical = await checkMigrations(historicalDb); + expect(historical).toMatchObject({ ready: true, pending: [], totalApplied: 32 }); + expect(historical.applied).toEqual([...REQUIRED_MIGRATIONS]); + expect(historicalDb.collection).toHaveBeenCalledExactlyOnceWith("_migrations"); + }); + + it.each(["029-create-users-collection.ts", "031-create-project-memberships.ts"])( + "still fails readiness without mandatory %s", + async (missing) => { + const db = makeMockDb(REQUIRED_MIGRATIONS.filter(file => file !== missing)); + expect(await checkMigrations(db)).toMatchObject({ ready: false, pending: [missing] }); + }, + ); + it("returns ready when all required migrations are applied", async () => { const db = makeMockDb([ "001-backfill-task-prompts.ts", @@ -61,11 +82,13 @@ describe("checkMigrations", () => { "028-isolate-mcp-secrets-per-project.ts", "029-create-users-collection.ts", "030-create-resource-indexes.ts", + "031-create-project-memberships.ts", + "032-scope-codebase-uniqueness.ts", ]); const result = await checkMigrations(db); expect(result.ready).toBe(true); expect(result.pending).toEqual([]); - expect(result.totalApplied).toBe(30); + expect(result.totalApplied).toBe(32); expect(result.applied).toEqual([ "001-backfill-task-prompts.ts", "002-create-indexes.ts", @@ -97,6 +120,7 @@ describe("checkMigrations", () => { "028-isolate-mcp-secrets-per-project.ts", "029-create-users-collection.ts", "030-create-resource-indexes.ts", + "031-create-project-memberships.ts", ]); }); @@ -104,7 +128,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", "029-create-users-collection.ts", "030-create-resource-indexes.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", "030-create-resource-indexes.ts", "031-create-project-memberships.ts"]); expect(result.applied).toEqual(["001-backfill-task-prompts.ts"]); expect(result.totalApplied).toBe(1); }); @@ -144,6 +168,7 @@ describe("checkMigrations", () => { "028-isolate-mcp-secrets-per-project.ts", "029-create-users-collection.ts", "030-create-resource-indexes.ts", + "031-create-project-memberships.ts", ]); expect(result.applied).toEqual([]); expect(result.totalApplied).toBe(0); @@ -181,12 +206,14 @@ describe("checkMigrations", () => { "028-isolate-mcp-secrets-per-project.ts", "029-create-users-collection.ts", "030-create-resource-indexes.ts", + "031-create-project-memberships.ts", + "032-scope-codebase-uniqueness.ts", "999-future-migration.ts", ]); const result = await checkMigrations(db); expect(result.ready).toBe(true); expect(result.pending).toEqual([]); - expect(result.totalApplied).toBe(31); + expect(result.totalApplied).toBe(33); }); it("caches results within TTL", async () => { diff --git a/packages/db-migrations/src/membership-authority.integration.test.ts b/packages/db-migrations/src/membership-authority.integration.test.ts new file mode 100644 index 000000000..06ebf2c3f --- /dev/null +++ b/packages/db-migrations/src/membership-authority.integration.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from "node:crypto"; +import { MongoClient, type Db } from "mongodb"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CreateProjectMemberships } from "./migrations/031-create-project-memberships.js"; + +const uri = process.env.AUTH_TEST_MONGO_URI; +describe.skipIf(!uri)("Native Mongo membership authority migration", () => { + let client: MongoClient; + let db: Db; + beforeEach(async () => { + client = await MongoClient.connect(uri!); + db = client.db(`rbac_membership_test_${randomUUID().replaceAll("-", "")}`); + }); + afterEach(async () => { await db.dropDatabase(); await client.close(); }); + + it("reruns without inventing grants and retains uniqueness after revoke/regrant", async () => { + const migration = new CreateProjectMemberships(); + await migration.up(db); + await migration.up(db); + const members = db.collection("project_memberships"); + expect(await members.countDocuments()).toBe(0); + await members.insertOne({ projectId: "a", userId: "bob", role: "reader", revision: 1 }); + await members.updateOne({ projectId: "a", userId: "bob", revision: 1 }, { $set: { revokedAt: new Date(), revision: 2 } }); + await expect(members.insertOne({ projectId: "a", userId: "bob", role: "admin" })).rejects.toMatchObject({ code: 11000 }); + await members.updateOne({ projectId: "a", userId: "bob", revision: 2 }, { $set: { role: "contributor", revision: 3 }, $unset: { revokedAt: "" } }); + expect(await members.countDocuments({ projectId: "a", userId: "bob" })).toBe(1); + expect(await members.findOne({ projectId: "a", userId: "bob" })).toMatchObject({ role: "contributor", revision: 3 }); + expect((await db.collection("admin_operations").listIndexes().toArray()).some(index => "expireAfterSeconds" in index)).toBe(false); + }); + + it("fails closed rather than silently accepting automatic administrative-evidence pruning", async () => { + await db.collection("admin_operations").createIndex({ createdAt: 1 }, { expireAfterSeconds: 86400 }); + await expect(new CreateProjectMemberships().up(db)).rejects.toThrow("TTL index"); + expect(await db.collection("admin_operations").listIndexes().toArray()).toEqual(expect.arrayContaining([ + expect.objectContaining({ expireAfterSeconds: 86400 }), + ])); + }); + + it("fails unfinished reports at cutover without changing stored terminal reports on rerun", async () => { + const reports = db.collection<{ + status: "pending" | "generating" | "completed" | "failed"; + updatedAt: Date; + content?: string; + error?: string; + }>("reports"); + const timestamp = new Date("2026-01-01T00:00:00Z"); + await reports.insertMany([ + { status: "pending", updatedAt: timestamp }, + { status: "generating", updatedAt: timestamp }, + { status: "completed", updatedAt: timestamp, content: "Stored report" }, + { status: "failed", updatedAt: timestamp, error: "Original failure" }, + ]); + const migration = new CreateProjectMemberships(); + await migration.up(db); + const first = await reports.find().sort({ _id: 1 }).toArray(); + expect(first).toEqual([ + expect.objectContaining({ status: "failed", error: "report_generation_disabled", updatedAt: expect.any(Date) }), + expect.objectContaining({ status: "failed", error: "report_generation_disabled", updatedAt: expect.any(Date) }), + expect.objectContaining({ status: "completed", updatedAt: timestamp, content: "Stored report" }), + expect.objectContaining({ status: "failed", updatedAt: timestamp, error: "Original failure" }), + ]); + expect(first[0].updatedAt.getTime()).toBeGreaterThan(timestamp.getTime()); + expect(first[1].updatedAt.getTime()).toBeGreaterThan(timestamp.getTime()); + await migration.up(db); + expect(await reports.find().sort({ _id: 1 }).toArray()).toEqual(first); + expect(await db.collection("project_memberships").countDocuments()).toBe(0); + }); +}); diff --git a/packages/db-migrations/src/migrate.test.ts b/packages/db-migrations/src/migrate.test.ts new file mode 100644 index 000000000..bec5d93bf --- /dev/null +++ b/packages/db-migrations/src/migrate.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { REQUIRED_MIGRATIONS } from "./required-migrations.js"; + +const mocks = vi.hoisted(() => ({ + cli: vi.fn(), + createIndex: vi.fn().mockResolvedValue("index"), + close: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("mongo-migrate-ts", () => ({ mongoMigrateCli: mocks.cli })); +vi.mock("mongodb", () => ({ + MongoClient: { + connect: vi.fn().mockResolvedValue({ + db: () => ({ collection: () => ({ createIndex: mocks.createIndex }) }), + close: mocks.close, + }), + }, +})); + +describe("migration CLI discovery", () => { + it("discovers the RBAC migrations without executing the deferred codebase change", async () => { + await import("./migrate.js"); + + expect(mocks.cli).toHaveBeenCalledOnce(); + const config = mocks.cli.mock.calls[0][0] as { + migrationsDir: string; + globPattern: string; + }; + expect(config.globPattern).toBe("**/*.ts"); + expect(config.migrationsDir).toBe(join(import.meta.dirname, "migrations")); + const discovered = readdirSync(config.migrationsDir, { recursive: true }) + .filter((file): file is string => typeof file === "string" && file.endsWith(".ts")) + .sort(); + expect(discovered).toEqual([...REQUIRED_MIGRATIONS].sort()); + expect(discovered).toContain("029-create-users-collection.ts"); + expect(discovered).toContain("031-create-project-memberships.ts"); + expect(discovered).not.toContain("032-scope-codebase-uniqueness.ts"); + expect(mocks.close).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/db-migrations/src/migrations/031-create-project-memberships.ts b/packages/db-migrations/src/migrations/031-create-project-memberships.ts new file mode 100644 index 000000000..09d010b13 --- /dev/null +++ b/packages/db-migrations/src/migrations/031-create-project-memberships.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { MongoServerError, type Db } from "mongodb"; +import type { MigrationInterface } from "mongo-migrate-ts"; + +export class CreateProjectMemberships implements MigrationInterface { + async up(db: Db): Promise { + const name = "project_memberships"; + const exists = await db.listCollections({ name }, { nameOnly: true }).hasNext(); + let cosmos = true; + try { + const result: unknown = await db.command(exists + ? { customAction: "GetCollection", collection: name } + : { customAction: "CreateCollection", collection: name, indexes: [ + { key: { _id: 1 }, name: "_id_1", unique: true }, + { key: { projectId: 1, userId: 1 }, name: "uniq_project_user", unique: true }, + ] }); + if (!result || typeof result !== "object" || !("ok" in result) || result.ok !== 1) { + throw new Error("[031] Invalid Cosmos collection response"); + } + } catch (error) { + if (!(error instanceof MongoServerError) || error.code !== 59) throw error; + cosmos = false; + } + const memberships = db.collection(name); + if (!cosmos) await memberships.createIndex({ projectId: 1, userId: 1 }, { name: "uniq_project_user", unique: true }); + const indexes = await memberships.listIndexes().toArray(); + const unique = indexes.find(index => index.name === "uniq_project_user"); + if (!unique || unique.unique !== true || unique.sparse || unique.partialFilterExpression || + JSON.stringify(unique.key) !== JSON.stringify({ projectId: 1, userId: 1 })) { + throw new Error("[031] Incompatible project membership uniqueness; operator repair required, no data was dropped"); + } + await memberships.createIndex({ userId: 1 }); + await memberships.createIndex({ projectId: 1 }); + await db.collection("admin_operations").createIndex({ state: 1 }); + await db.collection("admin_operations").createIndex({ targetId: 1 }); + const operationIndexes = await db.collection("admin_operations").listIndexes().toArray(); + if (operationIndexes.some(index => "expireAfterSeconds" in index)) { + throw new Error("[031] Administrative evidence has a TTL index; operator repair required before RBAC rollout"); + } + await db.collection("reports").updateMany({ status: { $in: ["pending", "generating"] } }, { + $set: { status: "failed", error: "report_generation_disabled", updatedAt: new Date() }, + }); + console.log("[031] Verified membership uniqueness; no legacy grants created; administrative intents have no TTL"); + } + + async down(): Promise { + console.log("[031-down] Retaining memberships and operation evidence; rollback must not reopen access"); + } +} diff --git a/packages/db-migrations/src/rbac-codebase-constraints.integration.test.ts b/packages/db-migrations/src/rbac-codebase-constraints.integration.test.ts new file mode 100644 index 000000000..4c2abbcdd --- /dev/null +++ b/packages/db-migrations/src/rbac-codebase-constraints.integration.test.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { MongoClient, type Db } from "mongodb"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { checkMigrations, resetMigrationCheckCache } from "./check-migrations.js"; +import { CreateProjectMemberships } from "./migrations/031-create-project-memberships.js"; +import { REQUIRED_MIGRATIONS } from "./required-migrations.js"; + +const uri = process.env.AUTH_TEST_MONGO_URI; +const execute = promisify(execFile); + +describe.skipIf(!uri)("RBAC release without the deferred codebase migration", () => { + let client: MongoClient; + let db: Db; + + beforeEach(async () => { + client = await MongoClient.connect(uri!); + db = client.db(`rbac_codebase_test_${randomUUID().replaceAll("-", "")}`); + resetMigrationCheckCache(); + }); + + afterEach(async () => { + await db.dropDatabase(); + await client.close(); + resetMigrationCheckCache(); + }); + + it("runs the actual CLI through 031, reruns safely and preserves legacy constraints", async () => { + const run = () => execute("pnpm", ["exec", "tsx", "src/migrate.ts", "up"], { + cwd: fileURLToPath(new URL("../", import.meta.url)), + env: { ...process.env, MONGODB_URI: uri, MONGODB_DATABASE: db.databaseName }, + timeout: 60_000, + }); + await run(); + const files = (await db.collection("_migrations").find().toArray()).map(row => row.file); + expect(files.sort()).toEqual([...REQUIRED_MIGRATIONS].sort()); + expect(files).not.toContain("032-scope-codebase-uniqueness.ts"); + expect(await checkMigrations(db)).toMatchObject({ ready: true, pending: [], totalApplied: 31 }); + + const codebases = db.collection("codebases"); + const revisions = db.collection("codebase-revisions"); + const before = await Promise.all([codebases.listIndexes().toArray(), revisions.listIndexes().toArray()]); + expect(before[0]).toEqual(expect.arrayContaining([expect.objectContaining({ key: { slug: 1 }, unique: true })])); + expect(before[1]).toEqual(expect.arrayContaining([expect.objectContaining({ key: { ref: 1 }, unique: true })])); + await run(); + expect(await Promise.all([codebases.listIndexes().toArray(), revisions.listIndexes().toArray()])).toEqual(before); + expect(await db.collection("_migrations").countDocuments()).toBe(31); + + const created = await Promise.allSettled([ + codebases.insertOne({ projectId: "a", slug: "concurrent" }), + codebases.insertOne({ projectId: "a", slug: "concurrent" }), + ]); + expect(created.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(created.filter(result => result.status === "rejected")).toHaveLength(1); + // A legacy global index still prevents reuse in another project (#1521). + await expect(codebases.insertOne({ projectId: "b", slug: "concurrent" })) + .rejects.toMatchObject({ code: 11000 }); + const attempts = await Promise.allSettled([ + revisions.insertOne({ projectId: "a", ref: "concurrent@r1" }), + revisions.insertOne({ projectId: "a", ref: "concurrent@r1" }), + ]); + expect(attempts.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(attempts.filter(result => result.status === "rejected")).toHaveLength(1); + }); + + it("retains previously installed project-local uniqueness when applying RBAC", async () => { + const codebases = db.collection("codebases"); + const revisions = db.collection("codebase-revisions"); + await codebases.createIndex({ projectId: 1, slug: 1 }, { name: "uniq_project_codebase_slug", unique: true }); + await revisions.createIndex({ projectId: 1, ref: 1 }, { name: "uniq_project_codebase_ref", unique: true }); + const before = await Promise.all([codebases.listIndexes().toArray(), revisions.listIndexes().toArray()]); + await new CreateProjectMemberships().up(db); + expect(await Promise.all([codebases.listIndexes().toArray(), revisions.listIndexes().toArray()])).toEqual(before); + + const concurrent = await Promise.allSettled([ + codebases.insertOne({ projectId: "a", slug: "shared" }), + codebases.insertOne({ projectId: "a", slug: "shared" }), + codebases.insertOne({ projectId: "b", slug: "shared" }), + ]); + expect(concurrent.filter(result => result.status === "fulfilled")).toHaveLength(2); + expect(concurrent.filter(result => result.status === "rejected")).toHaveLength(1); + const attempts = await Promise.allSettled([ + revisions.insertOne({ projectId: "a", ref: "shared@r1" }), + revisions.insertOne({ projectId: "a", ref: "shared@r1" }), + revisions.insertOne({ projectId: "b", ref: "shared@r1" }), + ]); + expect(attempts.filter(result => result.status === "fulfilled")).toHaveLength(2); + expect(attempts.filter(result => result.status === "rejected")).toHaveLength(1); + }); + + it("does not claim RBAC installs missing codebase uniqueness", async () => { + await new CreateProjectMemberships().up(db); + for (const [name, key] of [["codebases", "slug"], ["codebase-revisions", "ref"]] as const) { + const collection = db.collection(name); + await Promise.all([ + collection.insertOne({ projectId: "a", [key]: "same" }), + collection.insertOne({ projectId: "a", [key]: "same" }), + ]); + expect(await collection.countDocuments({ projectId: "a", [key]: "same" })).toBe(2); + expect((await collection.listIndexes().toArray()).filter(index => index.unique)).toEqual([]); + } + }); +}); diff --git a/packages/db-migrations/src/required-migrations.test.ts b/packages/db-migrations/src/required-migrations.test.ts index 690a8a187..ee5c5f6f7 100644 --- a/packages/db-migrations/src/required-migrations.test.ts +++ b/packages/db-migrations/src/required-migrations.test.ts @@ -20,6 +20,12 @@ describe("REQUIRED_MIGRATIONS", () => { } }); + it("requires identity and membership authority, not the deferred codebase index migration", () => { + expect(REQUIRED_MIGRATIONS).toContain("029-create-users-collection.ts"); + expect(REQUIRED_MIGRATIONS).toContain("031-create-project-memberships.ts"); + expect(REQUIRED_MIGRATIONS).not.toContain("032-scope-codebase-uniqueness.ts"); + }); + it("matches actual migration files on disk", () => { const migrationsDir = join(__dirname, "migrations"); const filesOnDisk = readdirSync(migrationsDir).filter((f) => diff --git a/packages/db-migrations/src/required-migrations.ts b/packages/db-migrations/src/required-migrations.ts index 3fd0899ed..59d8a30b1 100644 --- a/packages/db-migrations/src/required-migrations.ts +++ b/packages/db-migrations/src/required-migrations.ts @@ -43,4 +43,5 @@ export const REQUIRED_MIGRATIONS: readonly string[] = [ "028-isolate-mcp-secrets-per-project.ts", "029-create-users-collection.ts", "030-create-resource-indexes.ts", + "031-create-project-memberships.ts", ]; diff --git a/packages/shared/package.json b/packages/shared/package.json index db86b934f..d48295516 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,6 +5,11 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { + "./rbac": { + "source": "./src/rbac.ts", + "types": "./dist/rbac.d.ts", + "import": "./dist/rbac.js" + }, ".": { "source": "./src/index.ts", "types": "./dist/index.d.ts", diff --git a/packages/shared/src/auth/claims-enricher.test.ts b/packages/shared/src/auth/claims-enricher.test.ts index 780a94954..542723fbb 100644 --- a/packages/shared/src/auth/claims-enricher.test.ts +++ b/packages/shared/src/auth/claims-enricher.test.ts @@ -36,6 +36,14 @@ describe("ClaimsProfileEnricher", () => { displayName: undefined, emailVerified: undefined, }); + + }); + + it("keeps the sign-in alias distinct from an advisory email", async () => { + const profile = await new ClaimsProfileEnricher().enrich({ + ...identity, email: "shared@example.com", alias: "ada.login@example.com", emailVerified: false, + }, "raw-token"); + expect(profile).toMatchObject({ email: "shared@example.com", alias: "ada.login@example.com", emailVerified: false }); }); it("identifies itself as the claims enricher", () => { diff --git a/packages/shared/src/auth/claims-enricher.ts b/packages/shared/src/auth/claims-enricher.ts index 0395469cb..8480c6d2c 100644 --- a/packages/shared/src/auth/claims-enricher.ts +++ b/packages/shared/src/auth/claims-enricher.ts @@ -20,6 +20,7 @@ export class ClaimsProfileEnricher implements ProfileEnricher { ): Promise { return { email: identity.email, + ...(identity.alias !== undefined ? { alias: identity.alias } : {}), displayName: identity.displayName, emailVerified: identity.emailVerified, }; diff --git a/packages/shared/src/auth/config.test.ts b/packages/shared/src/auth/config.test.ts index bd169ed7d..9723c6671 100644 --- a/packages/shared/src/auth/config.test.ts +++ b/packages/shared/src/auth/config.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, expect, it } from "vitest"; -import { bootstrapAdminKey, loadAuthConfigFromEnv } from "./config.js"; +import { loadAuthConfigFromEnv } from "./config.js"; const TENANT = "11111111-1111-1111-1111-111111111111"; const SUBJECT = "aaaaaaaa-0000-0000-0000-000000000001"; @@ -17,8 +17,19 @@ function baseEnv(): NodeJS.ProcessEnv { } describe("loadAuthConfigFromEnv", () => { - it("returns null when AUTH_* is not configured", () => { - expect(loadAuthConfigFromEnv({})).toBeNull(); + it("fails closed when auth is enabled by default without IdP configuration", () => { + expect(() => loadAuthConfigFromEnv({})).toThrow(/AUTH_PROVIDER, AUTH_AUTHORITY, AUTH_API_CLIENT_ID/); + }); + + it.each(["false", "0", "no", "off", " FALSE "])("explicitly disables auth and ignores unused IdP settings with %j", value => { + expect(loadAuthConfigFromEnv({ SCOPE_AUTH_ENABLED: value })).toBeNull(); + expect(loadAuthConfigFromEnv({ + SCOPE_AUTH_ENABLED: value, AUTH_PROVIDER: "unused", AUTH_USER_CACHE_TTL_SECONDS: "unused", + })).toBeNull(); + }); + + it.each(["", "typo", "true", "1", "on", "yes"])("does not let %j bypass missing IdP configuration", value => { + expect(() => loadAuthConfigFromEnv({ SCOPE_AUTH_ENABLED: value })).toThrow(/Incomplete authentication configuration/); }); it("throws when any required variable is missing", () => { @@ -46,8 +57,6 @@ describe("loadAuthConfigFromEnv", () => { 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); }); @@ -68,11 +77,11 @@ describe("loadAuthConfigFromEnv", () => { })).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("requires explicit opt-out even when only a valid cache TTL is set", () => { + expect(() => loadAuthConfigFromEnv({ AUTH_USER_CACHE_TTL_SECONDS: "60" })).toThrow(/Incomplete authentication configuration/); }); - it("validates a supplied cache TTL even with authentication disabled", () => { + it("validates a supplied cache TTL when authentication defaults to enabled", () => { expect(() => loadAuthConfigFromEnv({ AUTH_USER_CACHE_TTL_SECONDS: "0" })) .toThrow(/AUTH_USER_CACHE_TTL_SECONDS/); }); @@ -93,35 +102,11 @@ describe("loadAuthConfigFromEnv", () => { 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/); + it.each(["AUTH_BOOTSTRAP_ADMINS", "AUTH_BOOTSTRAP_TENANTS"])("rejects retired %s rather than silently suggesting login can grant admin", key => { + expect(() => loadAuthConfigFromEnv({ ...baseEnv(), [key]: `entra:${TENANT}/${SUBJECT}` })) + .toThrow(/retired.*operator bootstrap-admin/); }); -}); - -describe("bootstrapAdminKey", () => { - it("formats the identity triple as `${idp}:${tenant}/${subject}`", () => { - expect(bootstrapAdminKey("entra", TENANT, SUBJECT)).toBe( - `entra:${TENANT}/${SUBJECT}`, - ); + it("accepts cleared retired settings without granting any bootstrap behavior", () => { + expect(loadAuthConfigFromEnv({ ...baseEnv(), AUTH_BOOTSTRAP_ADMINS: "", AUTH_BOOTSTRAP_TENANTS: " " })?.provider.id).toBe("entra"); }); }); diff --git a/packages/shared/src/auth/config.ts b/packages/shared/src/auth/config.ts index fe8edaf93..afcb90057 100644 --- a/packages/shared/src/auth/config.ts +++ b/packages/shared/src/auth/config.ts @@ -4,17 +4,7 @@ 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; +import { resolveAuthEnabledFlag } from "../rbac.js"; const REQUIRED_AUTH_ENV_KEYS = [ "AUTH_PROVIDER", @@ -28,35 +18,10 @@ export interface AuthRuntime { 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], @@ -79,16 +44,13 @@ function parseUserCacheTtl(raw: string | undefined): number { /** * 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. + * when SCOPE_AUTH_ENABLED explicitly disables authentication and RBAC. + * Missing or partial IdP configuration otherwise throws rather than opening access. * * 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_USER_CACHE_TTL_SECONDS` + * defaults to 300 when unset. IdP settings are unused when auth is disabled. * * `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 @@ -98,14 +60,11 @@ function parseUserCacheTtl(raw: string | undefined): number { 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; + if (!resolveAuthEnabledFlag(env.SCOPE_AUTH_ENABLED)) return null; + if (env.AUTH_BOOTSTRAP_ADMINS?.trim() || env.AUTH_BOOTSTRAP_TENANTS?.trim()) { + throw new Error("AUTH_BOOTSTRAP_ADMINS/AUTH_BOOTSTRAP_TENANTS are retired. Remove them and use the operator bootstrap-admin script after genuine enrollment."); } - + const userCacheTtlSeconds = parseUserCacheTtl(env.AUTH_USER_CACHE_TTL_SECONDS); const missingKeys = REQUIRED_AUTH_ENV_KEYS.filter( (key) => !env[key]?.trim(), ); @@ -135,20 +94,9 @@ export function loadAuthConfigFromEnv( ...(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 index f92510cfb..b79f72a6b 100644 --- a/packages/shared/src/auth/entra.test.ts +++ b/packages/shared/src/auth/entra.test.ts @@ -104,6 +104,7 @@ describe("EntraIdAuthProvider.verifyAccessToken", () => { idpTenant: TENANT, idpSubject: SUBJECT, email: "ada@example.com", + alias: "ada@example.com", displayName: "Ada Lovelace", emailVerified: true, }); @@ -159,6 +160,13 @@ describe("EntraIdAuthProvider.verifyAccessToken", () => { }); const identity = await makeProvider().verifyAccessToken(token); expect(identity.email).toBe("ada.primary@example.com"); + expect(identity.alias).toBe("ada@example.com"); + }); + + it("uses signed given/family names when the name claim is absent", async () => { + const payload = { ...basePayload(), name: undefined, given_name: "Ada", family_name: "Lovelace" }; + const identity = await makeProvider().verifyAccessToken(await sign(payload)); + expect(identity.displayName).toBe("Ada Lovelace"); }); it("rejects a token signed by a different key (bad signature)", async () => { diff --git a/packages/shared/src/auth/entra.ts b/packages/shared/src/auth/entra.ts index be7783aaa..244345e4f 100644 --- a/packages/shared/src/auth/entra.ts +++ b/packages/shared/src/auth/entra.ts @@ -132,7 +132,8 @@ export class EntraIdAuthProvider implements AuthProvider { const email = asString(payload.email) ?? asString(payload.preferred_username); - const displayName = asString(payload.name); + const displayName = asString(payload.name) ?? + ([asString(payload.given_name), asString(payload.family_name)].filter(Boolean).join(" ").trim() || undefined); const emailVerified = typeof payload.email_verified === "boolean" ? payload.email_verified @@ -143,6 +144,7 @@ export class EntraIdAuthProvider implements AuthProvider { idpTenant, idpSubject, email, + alias: asString(payload.preferred_username) ?? asString(payload.upn), displayName, emailVerified, }; diff --git a/packages/shared/src/auth/types.ts b/packages/shared/src/auth/types.ts index 41a00ea87..8a6690787 100644 --- a/packages/shared/src/auth/types.ts +++ b/packages/shared/src/auth/types.ts @@ -20,6 +20,8 @@ export interface VerifiedIdentity { idpSubject: string; /** Advisory email (Entra `email` / `preferred_username`). Never an authz input. */ email?: string; + /** Advisory sign-in alias (`preferred_username` / `upn`), never an authz input. */ + alias?: string; /** Display name (Entra `name`). */ displayName?: string; /** Whether the IdP asserted the email as verified. */ @@ -70,6 +72,7 @@ export class AuthError extends Error { /** Profile fields resolved for a user (from token claims today; Graph later). */ export interface UserProfile { email?: string; + alias?: string; displayName?: string; emailVerified?: boolean; } diff --git a/packages/shared/src/auth/user-schema.ts b/packages/shared/src/auth/user-schema.ts index 8a267dd2a..48feddd9d 100644 --- a/packages/shared/src/auth/user-schema.ts +++ b/packages/shared/src/auth/user-schema.ts @@ -6,13 +6,11 @@ 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. + * Canonical identity, platform role and disable authority. Project role bundles + * live in project_memberships. Legacy permission override fields stay inactive. */ export const UserDocumentSchema = z.object({ - /** Scope User ID — an app-owned UUID. Referenced by `ownerId` everywhere later. */ + /** Scope User ID — an app-owned UUID used by memberships and audit references. */ _id: z.string(), /** Provider id, e.g. "entra". */ idp: z.string(), @@ -23,8 +21,13 @@ export const UserDocumentSchema = z.object({ email: z.string().optional(), emailVerified: z.boolean().optional(), displayName: z.string().optional(), - /** Loose role string (default "user"). Intentionally NOT a union yet. */ + /** Advisory login label from signed profile claims; never an identity or permission key. */ + alias: z.string().optional(), + /** Stored platform role; canonical authorization rejects unknown values. */ role: z.string().default("user"), + revision: z.number().int().nonnegative().optional(), + lastOperationId: z.string().optional(), + appliedOperationIds: z.array(z.string()).optional(), /** Forward-compat, unused today. */ permissionsAdd: z.array(z.string()).optional(), /** Forward-compat, unused today. */ diff --git a/packages/shared/src/codebases/codebase-revision-store.test.ts b/packages/shared/src/codebases/codebase-revision-store.test.ts index e75e70901..9395de40e 100644 --- a/packages/shared/src/codebases/codebase-revision-store.test.ts +++ b/packages/shared/src/codebases/codebase-revision-store.test.ts @@ -165,7 +165,8 @@ describe("CodebaseRevisionStore", () => { const first = await revisionStore.createRevision(revisionInput(codebase)); const second = await revisionStore.createRevision(revisionInput(codebase)); - await expect(revisionStore.getByRef("pamela-fox-site@r1")).resolves.toEqual(first); + await expect(revisionStore.getByRef("proj-test", "pamela-fox-site@r1")).resolves.toEqual(first); + await expect(revisionStore.getByRef("another-project", "pamela-fox-site@r1")).resolves.toBeNull(); await expect(revisionStore.getLatest(codebase._id)).resolves.toEqual(second); await expect(revisionStore.listByCodebase(codebase._id)).resolves.toEqual([second, first]); }); @@ -190,7 +191,7 @@ describe("CodebaseRevisionStore", () => { // Direct id/ref/number lookups still resolve so runs keep working. await expect(revisionStore.get(first._id)).resolves.toMatchObject({ _id: first._id }); - await expect(revisionStore.getByRef(second.ref)).resolves.toMatchObject({ _id: second._id }); + await expect(revisionStore.getByRef("proj-test", second.ref)).resolves.toMatchObject({ _id: second._id }); await expect(revisionStore.getByNumber(codebase._id, 1)).resolves.toMatchObject({ _id: first._id }); }); diff --git a/packages/shared/src/codebases/codebase-revision-store.ts b/packages/shared/src/codebases/codebase-revision-store.ts index 9e1940fae..265d5242d 100644 --- a/packages/shared/src/codebases/codebase-revision-store.ts +++ b/packages/shared/src/codebases/codebase-revision-store.ts @@ -40,10 +40,11 @@ export class CodebaseRevisionStore { } /** Get a revision by its canonical `{slug}@r{N}` ref. */ - async getByRef(ref: string): Promise { + async getByRef(projectId: string, ref: string): Promise { const parsed = parseCodebaseRevisionRef(ref); if (!parsed || parsed.revisionNumber === undefined) return null; return this.collection.findOne({ + projectId, slug: parsed.slug, revisionNumber: parsed.revisionNumber, }); diff --git a/packages/shared/src/codebases/codebase-store.test.ts b/packages/shared/src/codebases/codebase-store.test.ts index 8faee02d0..5749abee8 100644 --- a/packages/shared/src/codebases/codebase-store.test.ts +++ b/packages/shared/src/codebases/codebase-store.test.ts @@ -77,10 +77,21 @@ function makeCodebaseCollection(seed: CodebaseDocument[] = []) { } type CodebaseStorePrivate = { - ensureUniqueSlug(base: string): Promise; + ensureUniqueSlug(projectId: string, base: string): Promise; }; describe("CodebaseStore", () => { + it("reserves and resolves colliding slugs independently in each project", async () => { + const collection = makeCodebaseCollection(); + const store = new CodebaseStore(collection as unknown as Collection); + const first = await store.create({ projectId: "project-a", name: "Same", sourceType: "archive" }); + const second = await store.create({ projectId: "project-b", name: "Same", sourceType: "archive" }); + expect(first.slug).toBe("same"); + expect(second.slug).toBe("same"); + expect(await store.getBySlug("project-a", "same")).toEqual(first); + expect(await store.getBySlug("project-b", "same")).toEqual(second); + expect(await store.getBySlug("project-c", "same")).toBeNull(); + }); it("creates a codebase with generated metadata and persisted fields", async () => { const collection = makeCodebaseCollection(); const store = new CodebaseStore(collection as unknown as Collection); @@ -113,9 +124,9 @@ describe("CodebaseStore", () => { const first = await store.create({ projectId: "proj-test", name: "First", sourceType: "archive" }); const second = await store.create({ projectId: "proj-test", name: "Second", sourceType: "archive" }); - expect(await store.getBySlug(first.slug)).toEqual(first); + expect(await store.getBySlug("proj-test", first.slug)).toEqual(first); expect(await store.softDelete(first._id)).toBe(true); - expect(await store.getBySlug(first.slug)).toBeNull(); + expect(await store.getBySlug("proj-test", first.slug)).toBeNull(); expect(await store.list()).toEqual([second]); expect((await store.get(first._id, { includeDeleted: true }))?.deletedAt).toBeInstanceOf(Date); }); diff --git a/packages/shared/src/codebases/codebase-store.ts b/packages/shared/src/codebases/codebase-store.ts index a6d0d2623..03a151e1c 100644 --- a/packages/shared/src/codebases/codebase-store.ts +++ b/packages/shared/src/codebases/codebase-store.ts @@ -56,9 +56,9 @@ export class CodebaseStore { return this.collection.findOne(filter as object) as Promise; } - /** Get a codebase by its unique slug (excludes soft-deleted by default). */ - async getBySlug(slug: string, opts?: { includeDeleted?: boolean }): Promise { - const filter: Record = { slug }; + /** Get a codebase by its project-local slug (excludes soft-deleted by default). */ + async getBySlug(projectId: string, slug: string, opts?: { includeDeleted?: boolean }): Promise { + const filter: Record = { projectId, slug }; if (!opts?.includeDeleted) filter.deletedAt = { $exists: false }; return this.collection.findOne(filter as object) as Promise; } @@ -96,7 +96,7 @@ export class CodebaseStore { const maxAttempts = 5; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { // eslint-disable-next-line no-await-in-loop - const slug = await this.ensureUniqueSlug(baseSlug); + const slug = await this.ensureUniqueSlug(input.projectId, baseSlug); const doc: CodebaseDocument = { _id: randomUUID(), projectId: input.projectId, @@ -216,11 +216,11 @@ export class CodebaseStore { * Find a slug not already taken, appending `-2`, `-3`, … as needed. * (Soft-deleted codebases still reserve their slug to keep refs stable.) */ - private async ensureUniqueSlug(base: string): Promise { + private async ensureUniqueSlug(projectId: string, base: string): Promise { let candidate = base; let n = 1; // eslint-disable-next-line no-await-in-loop - while (await this.collection.findOne({ slug: candidate } as object)) { + while (await this.collection.findOne({ projectId, slug: candidate } as object)) { n += 1; candidate = `${base}-${n}`; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a6febdece..74b5a7d07 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,3 +30,4 @@ export * from "./schemas/index.js"; export * from "./auth/index.js"; export * from "./cursor.js"; export * from "./run-duration.js"; +export * from "./rbac.js"; diff --git a/packages/shared/src/queue/queue-processor.test.ts b/packages/shared/src/queue/queue-processor.test.ts index 6a7ed97da..aa37b5cbf 100644 --- a/packages/shared/src/queue/queue-processor.test.ts +++ b/packages/shared/src/queue/queue-processor.test.ts @@ -33,6 +33,17 @@ const stubProcessor: WorkerProcessor = { }, }; +it("never calls the report API from the automatic coder completion trigger", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + try { + const qp = new CodingAgentQueueProcessor({ ...testConfig, apiBaseUrl: "http://api.invalid" }, stubProcessor); + const trigger: (id: string) => Promise = Reflect.get(qp, "triggerReportGeneration"); + await trigger.call(qp, "accepted-fixture"); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { vi.unstubAllGlobals(); } +}); + describe("CodingAgentQueueProcessor.getVersionFields", () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/packages/shared/src/queue/queue-processor.ts b/packages/shared/src/queue/queue-processor.ts index 2be51bf18..7552c1e66 100644 --- a/packages/shared/src/queue/queue-processor.ts +++ b/packages/shared/src/queue/queue-processor.ts @@ -3,6 +3,7 @@ import { DequeuedMessageItem, QueueClient } from "@azure/storage-queue"; import { DefaultAzureCredential } from "@azure/identity"; +import { REPORT_GENERATION_ENABLED } from "../rbac.js"; import os from "node:os"; import { RequestDocument, @@ -552,6 +553,10 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor { + if (!REPORT_GENERATION_ENABLED) { + console.info(`[${this.workerName}] Report generation disabled; request ${requestId} remains project work`); + return; + } const apiBaseUrl = (this.config as QueueProcessorConfig).apiBaseUrl; if (!apiBaseUrl) return; diff --git a/packages/shared/src/rbac.test.ts b/packages/shared/src/rbac.test.ts new file mode 100644 index 000000000..aed736665 --- /dev/null +++ b/packages/shared/src/rbac.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { + AccessCapabilitiesSchema, PlatformAdminListSchema, PlatformAdminPromotionSchema, + ProjectRoleSchema, projectPermissions, REPORT_GENERATION_ENABLED, resolveAuthEnabledFlag, +} from "./rbac.js"; + +describe("Shared authentication and RBAC switch", () => { + it.each([undefined, "", "typo", "f alse", "o ff", "true", "1", "yes", "on", " TRUE "])("keeps security enabled for %j", value => { + expect(resolveAuthEnabledFlag(value)).toBe(true); + }); + it.each(["false", "0", "no", "off", " OFF "])("accepts explicit opt-out %j", value => { + expect(resolveAuthEnabledFlag(value)).toBe(false); + }); +}); + +describe("Fixed project role bundles", () => { + it.each([ + ["reader", ["content:read"]], + ["contributor", ["content:read", "content:write", "content:delete"]], + ["admin", ["content:read", "content:write", "content:delete", "settings:write", "members:write"]], + ] as const)("%s has only its fixed project-scoped permissions", (role, expected) => { + expect(projectPermissions(role)).toEqual(expected); + }); + it("permits membership management only in the fixed admin bundle", () => { + const membership = { projectId: "project-a", role: "admin", permissions: projectPermissions("admin"), revision: 1 }; + expect(AccessCapabilitiesSchema.parse({ platformAdmin: false, reportsEnabled: false, memberships: [membership] }).platformAdmin).toBe(false); + for (const role of ["reader", "contributor"]) { + expect(AccessCapabilitiesSchema.safeParse({ + platformAdmin: false, reportsEnabled: false, memberships: [{ ...membership, role }], + }).success).toBe(false); + } + }); + + describe("Add-only platform administration contracts", () => { + it("accepts only a revision, never an arbitrary role, identity or disable flag", () => { + expect(PlatformAdminPromotionSchema.parse({ revision: 0 })).toEqual({ revision: 0 }); + for (const input of [{}, { revision: -1 }, { revision: 1.5 }, { revision: "0" }, + { revision: 0, role: "user" }, { revision: 0, disabled: false }, { revision: 0, email: "someone@example.test" }]) { + expect(PlatformAdminPromotionSchema.safeParse(input).success).toBe(false); + } + }); + + it("requires a platform role and explicit pending state in the paginated roster", () => { + const user = { id: "scope-user", role: "admin", disabled: false, revision: 1, pending: false }; + expect(PlatformAdminListSchema.parse({ items: [user], nextCursor: user.id }).items).toEqual([user]); + expect(PlatformAdminListSchema.safeParse({ items: [{ ...user, role: "user" }] }).success).toBe(false); + expect(PlatformAdminListSchema.safeParse({ items: [{ ...user, pending: undefined }] }).success).toBe(false); + }); + }); + it("rejects unapproved/custom roles and disables new report generation", () => { + for (const role of ["owner", "platform-admin", "*", "custom"]) expect(ProjectRoleSchema.safeParse(role).success).toBe(false); + expect(REPORT_GENERATION_ENABLED).toBe(false); + }); +}); diff --git a/packages/shared/src/rbac.ts b/packages/shared/src/rbac.ts new file mode 100644 index 000000000..fe28e3f78 --- /dev/null +++ b/packages/shared/src/rbac.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; + +/** Only an explicit opt-out disables authentication and project RBAC. */ +export function resolveAuthEnabledFlag(value: string | undefined): boolean { + return !["false", "0", "no", "off"].includes(value?.trim().toLowerCase() ?? ""); +} + +export const ApiAuthModeSchema = z.object({ authEnabled: z.boolean() }); + +export const ProjectRoleSchema = z.enum(["reader", "contributor", "admin"]); +export type ProjectRole = z.infer; +export type ProjectPermission = "content:read" | "content:write" | "content:delete" | "settings:write" | "members:write"; + +const permissions: Readonly> = { + reader: ["content:read"], + contributor: ["content:read", "content:write", "content:delete"], + admin: ["content:read", "content:write", "content:delete", "settings:write", "members:write"], +}; + +export function projectPermissions(role: ProjectRole): readonly ProjectPermission[] { + return permissions[role]; +} + +export const MembershipSchema = z.object({ + _id: z.string(), + projectId: z.string(), + userId: z.string(), + role: ProjectRoleSchema, + revision: z.number().int().positive(), + createdAt: z.date(), + updatedAt: z.date(), + createdByUserId: z.string(), + revokedAt: z.date().optional(), + lastOperationId: z.string(), + appliedOperationIds: z.array(z.string()), +}); +export type ProjectMembership = z.infer; + +export const AccessCapabilitiesSchema = z.object({ + platformAdmin: z.boolean(), + reportsEnabled: z.literal(false), + memberships: z.array(z.object({ + projectId: z.string().min(1), + role: ProjectRoleSchema, + permissions: z.array(z.enum(["content:read", "content:write", "content:delete", "settings:write", "members:write"])).readonly(), + revision: z.number().int().positive(), + }).strict().refine(membership => { + const expected = projectPermissions(membership.role); + return membership.permissions.length === expected.length && + expected.every(permission => membership.permissions.includes(permission)); + }, "Permissions must match the fixed project role")).refine( + memberships => new Set(memberships.map(membership => membership.projectId)).size === memberships.length, + "Project memberships must be unique", + ), +}).strict(); +export type AccessCapabilities = z.infer; + +export const AccessUserSummarySchema = z.object({ + id: z.string(), + displayName: z.string().optional(), + email: z.string().optional(), + alias: z.string().optional(), + role: z.string(), + disabled: z.boolean(), + revision: z.number().int().nonnegative(), +}); +export type AccessUserSummary = z.infer; + +export const AccessUserSearchResultSchema = z.object({ + items: z.array(AccessUserSummarySchema), + nextCursor: z.string().optional(), +}); +export type AccessUserSearchResult = z.infer; + +export const PlatformAdminSummarySchema = AccessUserSummarySchema.extend({ + role: z.literal("admin"), + pending: z.boolean(), + operationId: z.string().optional(), +}); +export type PlatformAdminSummary = z.infer; + +export const PlatformAdminListSchema = z.object({ + items: z.array(PlatformAdminSummarySchema), + nextCursor: z.string().optional(), +}); +export type PlatformAdminList = z.infer; + +export const PlatformAdminPromotionSchema = z.object({ + revision: z.number().int().nonnegative(), +}).strict(); + +export const ProjectMembershipSummarySchema = z.object({ + _id: z.string(), + projectId: z.string(), + userId: z.string(), + role: ProjectRoleSchema, + revision: z.number().int().positive(), + revokedAt: z.string().optional(), + pending: z.boolean(), + operationId: z.string().optional(), + user: AccessUserSummarySchema.optional(), +}); +export type ProjectMembershipSummary = z.infer; + +/** Report generation is deliberately unavailable until service authorization ships. */ +export const REPORT_GENERATION_ENABLED = false; diff --git a/packages/shared/src/schemas/project.ts b/packages/shared/src/schemas/project.ts index 385e3b623..3d2f16c1e 100644 --- a/packages/shared/src/schemas/project.ts +++ b/packages/shared/src/schemas/project.ts @@ -34,5 +34,8 @@ export const ProjectResponseSchema = z createdAt: z.coerce.date(), updatedAt: z.coerce.date().optional(), deletedAt: z.coerce.date().optional(), + revision: z.number().int().nonnegative().optional(), + lastOperationId: z.string().optional(), + appliedOperationIds: z.array(z.string()).optional(), }) .openapi("ProjectResponse"); diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index ee4fc27e6..3fc13aead 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -27,4 +27,7 @@ export interface ProjectDocument { createdAt: Date; updatedAt?: Date; deletedAt?: Date; // Soft-delete timestamp + revision?: number; + lastOperationId?: string; + appliedOperationIds?: string[]; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32aa2d058..14957c8ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -446,6 +446,9 @@ importers: remark-github-markdown-alerts: specifier: ^1.2.2 version: 1.2.2 + shared: + specifier: workspace:* + version: link:../../packages/shared sonner: specifier: ^1.7.0 version: 1.7.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff --git a/website/src/openapi/scope-openapi.json b/website/src/openapi/scope-openapi.json index a885790ae..4a8c4d8bf 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":{"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"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBinding"}},"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"]},"ResourceBinding":{"type":"object","properties":{"ref":{"type":"string"},"revisionId":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref","revisionId","params"]},"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"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRunOutcome"}},"mcpRegistered":{"type":"boolean"},"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"]},"ResourceRunOutcome":{"type":"object","properties":{"ref":{"type":"string"},"slug":{"type":"string"},"revisionId":{"type":"string"},"setupSucceeded":{"type":"boolean"},"published":{"type":"array","items":{"type":"string"}},"params":{"type":"object","additionalProperties":{"type":"string"}},"setupDurationMs":{"type":"number"},"error":{"type":"string"},"teardownRan":{"type":"boolean"}},"required":["ref","slug","revisionId","setupSucceeded","published"]},"ResourceBindingSpec":{"anyOf":[{"type":"string"},{"type":"object","properties":{"ref":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref"]}]},"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"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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"},"supportsResources":{"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"}}},"ResourceResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"latestRevisionNumber":{"type":"number"},"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","revisionCounter","createdAt","projectId"]},"CreateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"slug":{"type":"string","minLength":1},"description":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["name","setup"]},"ResourceScript":{"type":"object","properties":{"sh":{"type":"string","minLength":1}}},"ResourceParameter":{"type":"object","properties":{"name":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"},"description":{"type":"string"},"required":{"type":"boolean","default":false},"default":{"type":"string"},"example":{"type":"string"}},"required":["name"]},"UpdateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"ResourceRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"resourceId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"contentSha256":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","resourceId","slug","revisionNumber","ref","setup","exports","contentSha256","createdAt","projectId"]},"CreateResourceRevisionInput":{"type":"object","properties":{"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["setup"]},"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"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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/resources":{"get":{"tags":["Resources"],"summary":"List all resources","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/ResourceResponse"}}}}}}},"post":{"tags":["Resources"],"summary":"Create a resource and its first revision","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/CreateResourceInput"}}}},"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input"},"409":{"description":"Resource slug or revision ref already exists in this project"}}}},"/api/v1/resources/{id}":{"get":{"tags":["Resources"],"summary":"Get a resource","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/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"patch":{"tags":["Resources"],"summary":"Update a resource","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/UpdateResourceInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"delete":{"tags":["Resources"],"summary":"Delete a resource","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"},"404":{"description":"Resource not found"}}}},"/api/v1/resources/{id}/revisions":{"get":{"tags":["Resource Revisions"],"summary":"List resource revisions","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"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}}},"404":{"description":"Resource not found"}}},"post":{"tags":["Resource Revisions"],"summary":"Create a resource revision","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/CreateResourceRevisionInput"}}}},"responses":{"201":{"description":"Success"},"404":{"description":"Resource not found"},"409":{"description":"Resource revision ref already exists in this project"}}}},"/api/v1/resources/{id}/revisions/latest":{"get":{"tags":["Resource Revisions"],"summary":"Get the latest resource revision","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/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/{id}/revisions/{revisionNumber}":{"get":{"tags":["Resource Revisions"],"summary":"Get a resource revision by resource and revision number","parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"revisionNumber","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/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/revisions/{id}":{"get":{"tags":["Resource Revisions"],"summary":"Get resource revision 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/ResourceRevisionResponse"}}}},"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"},"capabilities":{"type":"object","properties":{"platformAdmin":{"type":"boolean"},"reportsEnabled":{"type":"boolean","enum":[false]},"memberships":{"type":"array","items":{"type":"object","properties":{"projectId":{"type":"string","minLength":1},"role":{"type":"string","enum":["reader","contributor","admin"]},"permissions":{"type":"array","items":{"type":"string","enum":["content:read","content:write","content:delete","settings:write","members:write"]}},"revision":{"type":"integer","exclusiveMinimum":0}},"required":["projectId","role","permissions","revision"],"additionalProperties":false}}},"required":["platformAdmin","reportsEnabled","memberships"],"additionalProperties":false}},"required":["id","capabilities"]},"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"},"revision":{"type":"integer","minimum":0},"lastOperationId":{"type":"string"},"appliedOperationIds":{"type":"array","items":{"type":"string"}}},"required":["_id","name","createdAt"]},"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"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBinding"}},"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"]},"ResourceBinding":{"type":"object","properties":{"ref":{"type":"string"},"revisionId":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref","revisionId","params"]},"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"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRunOutcome"}},"mcpRegistered":{"type":"boolean"},"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"]},"ResourceRunOutcome":{"type":"object","properties":{"ref":{"type":"string"},"slug":{"type":"string"},"revisionId":{"type":"string"},"setupSucceeded":{"type":"boolean"},"published":{"type":"array","items":{"type":"string"}},"params":{"type":"object","additionalProperties":{"type":"string"}},"setupDurationMs":{"type":"number"},"error":{"type":"string"},"teardownRan":{"type":"boolean"}},"required":["ref","slug","revisionId","setupSucceeded","published"]},"ResourceBindingSpec":{"anyOf":[{"type":"string"},{"type":"object","properties":{"ref":{"type":"string"},"params":{"type":"object","additionalProperties":{"type":"string"}}},"required":["ref"]}]},"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"},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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"},"supportsResources":{"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"}}},"ResourceResponse":{"type":"object","properties":{"_id":{"type":"string"},"slug":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"revisionCounter":{"type":"number"},"latestRevisionId":{"type":"string"},"latestRevisionNumber":{"type":"number"},"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","revisionCounter","createdAt","projectId"]},"CreateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"slug":{"type":"string","minLength":1},"description":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["name","setup"]},"ResourceScript":{"type":"object","properties":{"sh":{"type":"string","minLength":1}}},"ResourceParameter":{"type":"object","properties":{"name":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"},"description":{"type":"string"},"required":{"type":"boolean","default":false},"default":{"type":"string"},"example":{"type":"string"}},"required":["name"]},"UpdateResourceInput":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"}}},"ResourceRevisionResponse":{"type":"object","properties":{"_id":{"type":"string"},"resourceId":{"type":"string"},"slug":{"type":"string"},"revisionNumber":{"type":"number"},"ref":{"type":"string"},"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"contentSha256":{"type":"string"},"creator":{"type":"string"},"createdAt":{"type":["string","null"],"format":"date-time"},"deduplicated":{"type":"boolean"},"projectId":{"type":"string"}},"required":["_id","resourceId","slug","revisionNumber","ref","setup","exports","contentSha256","createdAt","projectId"]},"CreateResourceRevisionInput":{"type":"object","properties":{"setup":{"$ref":"#/components/schemas/ResourceScript"},"teardown":{"$ref":"#/components/schemas/ResourceScript"},"exports":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z_][A-Za-z0-9_]*$"}},"parameters":{"type":"array","items":{"$ref":"#/components/schemas/ResourceParameter"}},"creator":{"type":"string"}},"required":["setup"]},"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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 with the default user role, refresh their profile and lastLoginAt, and warm the access cache. Login never grants administrative privileges. Use this only after an explicit IdP login; do not prefetch, poll, or automatically retry transient failures.","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":false}}}},"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"}}}},"/api/v1/admin/platform-admins":{"get":{"tags":["Access"],"summary":"List platform administrators, including disabled accounts and pending changes","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1,"maxLength":200},"required":false,"name":"cursor","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":50,"default":20},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"alias":{"type":"string"},"role":{"type":"string","enum":["admin"]},"disabled":{"type":"boolean"},"revision":{"type":"integer","minimum":0},"pending":{"type":"boolean"},"operationId":{"type":"string"}},"required":["id","role","disabled","revision","pending"]}},"nextCursor":{"type":"string"}},"required":["items"]}}}}}}},"/api/v1/admin/users/{id}/promote":{"post":{"tags":["Access"],"summary":"Promote an enabled enrolled user to platform administrator","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"revision":{"type":"integer","minimum":0}},"required":["revision"],"additionalProperties":false}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"operationId":{"type":"string"},"state":{"type":"string","enum":["completed","pending"]},"revision":{"type":"integer","exclusiveMinimum":0}},"required":["operationId","state","revision"]}}}},"202":{"description":"Promotion is pending; retry identical input or ask an operator to inspect/repair the same operation ID before claiming completion"},"404":{"description":"Enrolled user not found"},"409":{"description":"Revision, role, enabled state or idempotency precondition conflict"}}}},"/api/v1/admin/users/search":{"get":{"tags":["Access"],"summary":"Search enrolled platform users (platform administration)","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","maxLength":100,"default":""},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":200},"required":false,"name":"cursor","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":50,"default":20},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"alias":{"type":"string"},"role":{"type":"string"},"disabled":{"type":"boolean"},"revision":{"type":"integer","minimum":0}},"required":["id","role","disabled","revision"]}},"nextCursor":{"type":"string"}},"required":["items"]}}}}}}},"/api/v1/projects/{id}/members/search":{"get":{"tags":["Access"],"summary":"Search enrolled users to add to this project (project or platform administration)","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","maxLength":100,"default":""},"required":false,"name":"q","in":"query"},{"schema":{"type":"string","minLength":1,"maxLength":200},"required":false,"name":"cursor","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":50,"default":20},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"alias":{"type":"string"},"role":{"type":"string"},"disabled":{"type":"boolean"},"revision":{"type":"integer","minimum":0}},"required":["id","role","disabled","revision"]}},"nextCursor":{"type":"string"}},"required":["items"]}}}}}}},"/api/v1/admin/users":{"get":{"tags":["Access"],"summary":"Look up an exactly identified enrolled user","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":false,"name":"userId","in":"query"},{"schema":{"type":"string"},"required":false,"name":"idp","in":"query"},{"schema":{"type":"string"},"required":false,"name":"tenant","in":"query"},{"schema":{"type":"string"},"required":false,"name":"subject","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"alias":{"type":"string"},"role":{"type":"string"},"disabled":{"type":"boolean"},"revision":{"type":"integer","minimum":0}},"required":["id","role","disabled","revision"]}}}}}}}},"/api/v1/projects/{id}/members/lookup/{userId}":{"get":{"tags":["Access"],"summary":"Look up one enrolled Scope UUID for project membership management","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string","minLength":1},"required":true,"name":"id","in":"path"},{"schema":{"type":"string","format":"uuid"},"required":true,"name":"userId","in":"path"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"alias":{"type":"string"},"role":{"type":"string"},"disabled":{"type":"boolean"},"revision":{"type":"integer","minimum":0}},"required":["id","role","disabled","revision"]}}}}}}},"/api/v1/projects/{id}/members":{"get":{"tags":["Access"],"summary":"List project memberships (project or platform administration)","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"_id":{"type":"string"},"projectId":{"type":"string"},"userId":{"type":"string"},"role":{"type":"string","enum":["reader","contributor","admin"]},"revision":{"type":"integer","exclusiveMinimum":0},"revokedAt":{"type":"string"},"pending":{"type":"boolean"},"operationId":{"type":"string"},"user":{"type":"object","properties":{"id":{"type":"string"},"displayName":{"type":"string"},"email":{"type":"string"},"alias":{"type":"string"},"role":{"type":"string"},"disabled":{"type":"boolean"},"revision":{"type":"integer","minimum":0}},"required":["id","role","disabled","revision"]}},"required":["_id","projectId","userId","role","revision","pending"]}}}}}}}},"/api/v1/projects/{id}/members/{userId}":{"put":{"tags":["Access"],"summary":"Explicitly grant or change a project membership","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"role":{"type":"string","enum":["reader","contributor","admin"]},"revision":{"type":"integer","minimum":0}},"required":["role","revision"],"additionalProperties":false}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{}}}}}},"delete":{"tags":["Access"],"summary":"Revoke a project membership","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"revision":{"type":"integer","minimum":0}},"required":["revision"],"additionalProperties":false}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{}}}}}}},"/api/v1/admin/users/{id}/disable":{"post":{"tags":["Access"],"summary":"Disable an ordinary user","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"revision":{"type":"integer","minimum":0}},"required":["revision"],"additionalProperties":false}}}},"responses":{"201":{"description":"Success","content":{"application/json":{"schema":{}}}}}}},"/health":{"get":{"tags":["Health"],"summary":"Liveness probe","security":[],"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","security":[],"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","security":[],"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","security":[],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"object","properties":{"commit":{"type":"string"},"buildTime":{"type":"string"},"environment":{"type":"string"},"strictAgentCapabilities":{"type":"boolean"},"authEnabled":{"type":"boolean"}},"required":["commit","buildTime","environment","strictAgentCapabilities","authEnabled"]}}}}}}},"/api/v1/projects":{"get":{"tags":["Projects"],"summary":"List all projects","security":[{"bearerAuth":[]}],"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"},{"schema":{"type":"string","enum":["true","false"]},"required":false,"name":"controlPlane","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ProjectResponse"}}}}}}},"post":{"tags":["Projects"],"summary":"Create a project","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"creator":{"type":"string"}},"required":["name"],"additionalProperties":false}}}},"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","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProjectResponse"}}}},"404":{"description":"Project not found"}}},"patch":{"tags":["Projects"],"summary":"Update a project","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1},"description":{"type":"string"},"revision":{"type":"integer","minimum":0}},"required":["revision"],"additionalProperties":false}}}},"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","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"revision":{"type":"integer","minimum":0}},"required":["revision"],"additionalProperties":false}}}},"responses":{"204":{"description":"Success"},"404":{"description":"Project not found"}}}},"/api/v1/projects/{id}/restore":{"post":{"tags":["Projects"],"summary":"Restore a soft-deleted project","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"revision":{"type":"integer","minimum":0}},"required":["revision"],"additionalProperties":false}}}},"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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.","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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=.","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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":{"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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":{"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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":{"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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":{"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateReportInput"}}}},"responses":{"400":{"description":"Invalid input"},"404":{"description":"Run or template not found"},"503":{"description":"New report generation is disabled (report_generation_disabled); no work is enqueued"}}},"get":{"tags":["Reports"],"summary":"List reports","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkCreateReportsInput"}}}},"responses":{"400":{"description":"Invalid input"},"503":{"description":"New report generation is disabled (report_generation_disabled); no work is enqueued"}}}},"/api/v1/reports/bulk-status":{"post":{"tags":["Reports"],"summary":"Bulk get report statuses","security":[{"bearerAuth":[]}],"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).","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerReportsInput"}}}},"responses":{"400":{"description":"Invalid input"},"404":{"description":"Run not found"},"503":{"description":"New report generation is disabled (report_generation_disabled); no work is enqueued"}}}},"/api/v1/reports/bulk-trigger":{"post":{"tags":["Reports"],"summary":"Bulk trigger reports","security":[{"bearerAuth":[]}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkTriggerReportsInput"}}}},"responses":{"400":{"description":"Invalid input"},"503":{"description":"New report generation is disabled (report_generation_disabled); no work is enqueued"}}}},"/api/v1/reports/{id}/insights":{"get":{"tags":["Reports"],"summary":"Get insights for report","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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":{"404":{"description":"Report or insight not found"},"409":{"description":"Insight already referenced by this report"},"503":{"description":"New report generation is disabled (report_generation_disabled); no work is enqueued"}}}},"/api/v1/profiles":{"post":{"tags":["Profiles"],"summary":"Create a new profile","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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"}},"resources":{"type":"array","items":{"$ref":"#/components/schemas/ResourceBindingSpec"}},"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseResponse"}}}},"404":{"description":"Codebase not found"}}},"patch":{"tags":["Codebases"],"summary":"Update a codebase","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"responses":{"204":{"description":"Success"},"404":{"description":"Codebase not found"}}}},"/api/v1/codebases/{id}/revisions":{"get":{"tags":["Codebases"],"summary":"List codebase revisions","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CodebaseRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources":{"get":{"tags":["Resources"],"summary":"List all resources","security":[{"bearerAuth":[]}],"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/ResourceResponse"}}}}}}},"post":{"tags":["Resources"],"summary":"Create a resource and its first revision","security":[{"bearerAuth":[]}],"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/CreateResourceInput"}}}},"responses":{"201":{"description":"Success"},"400":{"description":"Invalid input"},"409":{"description":"Resource slug or revision ref already exists in this project"}}}},"/api/v1/resources/{id}":{"get":{"tags":["Resources"],"summary":"Get a resource","security":[{"bearerAuth":[]}],"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/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"patch":{"tags":["Resources"],"summary":"Update a resource","security":[{"bearerAuth":[]}],"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/UpdateResourceInput"}}}},"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResourceResponse"}}}},"404":{"description":"Resource not found"}}},"delete":{"tags":["Resources"],"summary":"Delete a resource","security":[{"bearerAuth":[]}],"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"},"404":{"description":"Resource not found"}}}},"/api/v1/resources/{id}/revisions":{"get":{"tags":["Resource Revisions"],"summary":"List resource revisions","security":[{"bearerAuth":[]}],"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"},{"schema":{"type":"string"},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Success","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ResourceRevisionResponse"}}}}},"404":{"description":"Resource not found"}}},"post":{"tags":["Resource Revisions"],"summary":"Create a resource revision","security":[{"bearerAuth":[]}],"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/CreateResourceRevisionInput"}}}},"responses":{"201":{"description":"Success"},"404":{"description":"Resource not found"},"409":{"description":"Resource revision ref already exists in this project"}}}},"/api/v1/resources/{id}/revisions/latest":{"get":{"tags":["Resource Revisions"],"summary":"Get the latest resource revision","security":[{"bearerAuth":[]}],"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/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/{id}/revisions/{revisionNumber}":{"get":{"tags":["Resource Revisions"],"summary":"Get a resource revision by resource and revision number","security":[{"bearerAuth":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"id","in":"path"},{"schema":{"type":"string"},"required":true,"name":"revisionNumber","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/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/resources/revisions/{id}":{"get":{"tags":["Resource Revisions"],"summary":"Get resource revision by id","security":[{"bearerAuth":[]}],"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/ResourceRevisionResponse"}}}},"404":{"description":"Revision not found"}}}},"/api/v1/extensions":{"get":{"tags":["Extensions"],"summary":"List all extensions","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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)","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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","security":[{"bearerAuth":[]}],"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