{"owner":"Infisical","repo":"infisical","hasSkills":true,"totalSkillsCount":11,"totalTokensCount":39134,"categories":["root-instruction","claude-rule","anthropic-skill","project-spec"],"hasMcp":false,"mcpConfig":null,"found":["AGENTS.md","CLAUDE.md","backend-go/CLAUDE.md",".agents/skills/testing-secrets-ui/SKILL.md","tests/CLAUDE.md","e2e/CLAUDE.md","frontend/CLAUDE.md","backend/CLAUDE.md","DESIGN.md",".agents/skills/docs-style/templates/README.md",".agents/skills/docs-style/SKILL.md"],"skills":{"AGENTS.md":"## Feature Development\n\nCheck out CLAUDE.md for additional context on project file structure and general feature development.\n\n## Backend Development\n\n**Read the [Backend Code Quality Guide](backend/CODE_QUALITY.md) for any change under `backend/`, and check your work against it before reporting the task done.** Features, refactors, bug fixes, and reviews all count.\n\nIt is a short, deliberately non-exhaustive floor: error messages users can understand (and no pointless 500s), validation on every API input, correct pagination when calling third-party APIs, no deadlock conditions on a small connection pool, REST-aligned API interfaces, and audit log events an admin can actually read.\n\nTreat that list as a summary of the guide's current contents, not as a condition for reading it. A change that does not look like any of those topics still gets checked, because the guide grows and because the items apply in places they are not obviously about (a bug fix that adds a query inside an existing transaction, a refactor that moves a third-party list call).\n\nSome of it needs judgment rather than a mechanical check. The deadlock rules cannot be caught by testing one request at a time. And a design that breaks REST should be raised with the author, with the conforming alternative proposed, rather than implemented silently or quietly \"fixed\" (some deviations are deliberate).\n\n## Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md), including the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/` (or `make lint-docs` for the whole site; the `Check docs style` CI check runs the branch variant). It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide. A clean run is not a substitute for reading the guide: the judgment calls it cannot check are the ones that matter most. Vale cannot see prose indented inside Mintlify components, so a clean run is not evidence that nested content was checked, and `Infisical.EmDashes` reports at warning level, so its findings never change the exit code.\n\n## UI Development\n\nWhen building frontend UI, follow [DESIGN.md](DESIGN.md) for the v3 design system — colors, typography, components, and voice.\n\nBefore changing a shared frontend component's lifecycle, read the [shared component lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) for current deprecations, blockers, and replacement decisions.\n\nPickers for entities a user may need to create first (app connections, gateways, and similar) must offer an inline \"Create new X\" row, gated on the create permission, so the user never has to leave the flow. Build them with the v3 `Combobox` and its `creation` prop, which renders that row; `Combobox` replaces both the deprecated `FilterableSelect` and `CreatableSelect`. For app connections, use `creation.mode = \"dialog\"` and have `renderForm` render `AppConnectionForm` with the app preset, selecting the new connection and calling `complete` in its `onComplete`, and route `onDismiss` and the form's cancel through `useDiscardChangesGuard` (see `CaInstallCertModal/useConnectionCreation.tsx`). A v2 `Modal` parent sits at `z-[60]`, above v3 overlays, so migrate it to the v3 `Dialog` when it hosts one (see `CaInstallCertModal/CaInstallCertModal.tsx`).\n\n## Issue and PR Guidelines\n\n- Never create a GitHub issue.\n- When creating a pull request, use and fully complete the repository's\n  `.github/pull_request_template.md` template.\n\n## Workflow Guardrails\n\n- Do not reformat existing code as incidental cleanup. Preserve the repository's current formatting unless a review agent, CI check, or user explicitly asks for a formatting change.\n- Never push changes to `license-fns` or local UI fixtures to a remote branch. Those edits are local-only support for review and test passes; keep them out of commits and pull requests.\n- When resolving merge conflicts, optimize for a correct, minimal, strategically unblocking resolution. Do not let a slow or stalled local check block the push when the corresponding frontend or backend CI checks on GitHub can validate the branch; push the resolved code and use CI to complete the expensive verification.\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Essential Commands\n\n- `make reviewable-api` / `make reviewable-ui` — lint:fix + type:check (run before PRs). Neither checks [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md); review backend changes against it yourself.\n- `cd backend && npm run migration:new` — create new DB migration\n- `cd backend && npm run generate:schema` — regenerate Zod types from DB after migration changes\n- `make test-api-unit` / `make test-api-e2e` — run the Node backend suites in the FIPS image CI uses, against a throwaway database. Narrow with `SPEC=<pattern>`. See `backend/CLAUDE.md` for why running `npm run test:e2e` directly can destroy your dev data.\n- `cd backend-go && make test` — run Go integration tests\n\nBoth backend and frontend use `@app/*` as path alias to `./src/*`.\n\n## Repository Structure\n\nInfisical is an open-source secret management platform. Monorepo layout:\n\n```\ninfisical/\n├── backend/               # Fastify 4 API server (see backend/CLAUDE.md)\n├── backend-go/            # Go API server — partial rewrite (see backend-go/CLAUDE.md)\n├── frontend/              # React 19 SPA (see frontend/CLAUDE.md)\n├── wasm/                  # Rust crates compiled to WASM for the frontend (see wasm/<crate>/CLAUDE.md)\n├── e2e/                   # External Playwright suite — gates prod deploys against gamma (see e2e/CLAUDE.md)\n├── tests/                 # Go blackbox suite — real containers, HTTP only (see tests/CLAUDE.md)\n├── docs/                  # Documentation site (Mintlify-based)\n├── build-versions.env            # Versions pinned across several Dockerfiles (see Dependency Policy)\n├── docker-compose.dev.yml        # Local dev (PostgreSQL, Redis, backend, frontend, Nginx)\n├── docker-compose.prod.yml       # Production deployment stack\n├── docker-compose.bdd.yml        # BDD testing environment\n├── docker-compose.e2e-dbs.yml    # E2E test databases (Oracle, SAP, Snowflake, etc.)\n├── Dockerfile.standalone-infisical       # Standalone image (frontend + backend)\n├── Dockerfile.fips.standalone-infisical  # FIPS-compliant standalone image\n├── .github/               # CI workflows, PR template\n└── CLAUDE.md               # This file\n```\n\n- **`backend/`** — Fastify 4 API server, TypeScript, PostgreSQL via Knex, BullMQ queues. See [`backend/CLAUDE.md`](backend/CLAUDE.md) for architecture, patterns, and commands.\n- **`backend-go/`** — Go API server (partial rewrite), chi + chita framework, raw pgx queries, same PostgreSQL database. See [`backend-go/CLAUDE.md`](backend-go/CLAUDE.md) for architecture, patterns, and commands.\n- **`frontend/`** — React 19 SPA, Vite 8, TanStack Router + React Query, Tailwind CSS v4. See [`frontend/CLAUDE.md`](frontend/CLAUDE.md) for architecture, patterns, and commands.\n- **`wasm/`** — Rust crates that compile to WASM for the frontend. Generated bindings are committed under `frontend/src/lib/<crate>/` so the frontend builds without a Rust toolchain. Each crate has its own `CLAUDE.md` with the rebuild command (e.g. [`wasm/ironrdp-decoder/CLAUDE.md`](wasm/ironrdp-decoder/CLAUDE.md)) — run it after any change to that crate's `src/` or `Cargo.toml` so source and bindings stay in sync.\n- **`docs/`** — Product documentation site. Has its own Dockerfile for building. Reference docs for up-to-date feature descriptions and API usage.\n- **`e2e/`** — Playwright suite that runs against a deployed environment (gamma) between deploy and prod promotion. Distinct from `backend/e2e-test/` (in-process Vitest). Failure blocks every prod-deploy job. Covers SCIM + SAML flows (SP-initiated, IdP-initiated, deactivation, response rejection) against a mock IdP we control — see [`e2e/CLAUDE.md`](e2e/CLAUDE.md) for the harness and the one-time gamma bootstrap.\n\n- **`tests/`** — Go blackbox suite. Boots a real Infisical in Docker and talks to it over HTTP only, so the same tests run against the Node and Go servers unchanged. Distinct from `backend/e2e-test/` (in-process Vitest, compiled against the Node source) and from `e2e/` (Playwright against a deployed gamma). `make test-suites` from `tests/` runs everything; see [`tests/CLAUDE.md`](tests/CLAUDE.md) before writing one.\n\nEnterprise features live in `backend/src/ee/` (services and routes), registered before community routes so they can override/extend them.\n\n### Self-Hosted Deployment\n\nInfisical supports self-hosted deployment via Docker. Key considerations:\n- **`Dockerfile.standalone-infisical`** — single-container image with both frontend and backend; used for simple deployments.\n- **`Dockerfile.fips.standalone-infisical`** — FIPS 140-2 compliant variant for regulated environments. Be strict about not introducing dependencies that break FIPS compliance.\n- **`docker-compose.prod.yml`** — production compose with backend, PostgreSQL, and Redis.\n- New backend dependencies should be evaluated carefully — they affect container size, FIPS compliance, and the encryption boundary. Check `docs/` for self-hosted deployment documentation when in doubt.\n\n### Dependency Policy\n\n`build-versions.env` holds versions that several Dockerfiles install, currently the bundled\nInfisical CLI. Each Dockerfile keeps a matching `ARG` default so external builders (Northflank,\nRender, a plain `docker build`) work without passing anything, and CI and compose override it from\nthat file. `check-dockerfile-pins.yml` fails a PR when the defaults drift from it, so change the\nfile and the `ARG` defaults together.\n\nBoth `backend/` and `frontend/` enforce a minimum release age of 7 days for npm packages (configured via `.npmrc` in each directory). This means `npm install` will only resolve package versions published at least 7 days ago, as a supply-chain security measure.\n\nThe backend installs with the npm bundled in Node 26 (11.x), which skips dependency install scripts unless `allowScripts` in `backend/package.json` approves them. The install still succeeds when a script is skipped, so a native addon that was not approved fails only when it is first `require`d. When adding or bumping a dependency with an install script, run `npm install-scripts ls` and approve or deny what it lists (`npm install-scripts approve <pkg>`).\n\n## Cross-Cutting Patterns\n\n### Backend Code Quality\n\n**Read [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md) for every backend change, and check the change against it before calling the work done.** This applies to all work under `backend/`: new features, refactors, bug fixes, and reviews alike.\n\nIt is a floor, not an exhaustive standard: user-understandable error messages and no pointless 500s, explicit validation on every API input, correct pagination when calling third-party APIs, avoiding deadlock conditions on a small connection pool (thread `tx`, keep transactions short), REST-aligned API interfaces (flag deviations for the author to confirm rather than implementing them silently), and audit log events an admin can actually read.\n\nThat list describes what the guide currently covers; it is **not** a test for whether the guide applies. Do not skip it because a change does not look like one of those topics. Read it, then decide which items are relevant.\n\n### Code Comments\n\n**Default to no comments.** One earns its place only by explaining *why*: a non-obvious constraint, a workaround, an ordering dependency, or logic that looks wrong until you know the reason.\n\nNever write: narration restating the next line; section headers inside a function (`// --- validation ---`); change history (`// Added retry logic`, `// NEW`); references to plans, tickets, PRs, or reviewers; docstrings restating the signature; commented-out code.\n\nBefore finishing, delete any comment you added that only says what the code says.\n\n### Product Analytics\n\nRead [`ANALYTICS.md`](ANALYTICS.md) before adding or changing product analytics. Every event has one owning producer: the frontend records UI exposure and intent, the application backend records accepted commands it can establish, and the system of record records durable outcomes. Define stable dimensions, cardinality, firing conditions, and dashboard consumers before implementation.\n\n### Design System & Voice\n\nThe v3 visual system (colors, typography, components, layout) and product voice/content tone are documented in [`DESIGN.md`](DESIGN.md). Read it before producing new UI or user-visible copy.\n\n### Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md): what belongs on a page, and the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/`. It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide: sentence case in headings and frontmatter titles, product and vendor spellings, spelling against a curated vocabulary, `$` prompts in code blocks, and em dash density. See [docs/CONTRIBUTING.MD](docs/CONTRIBUTING.MD) for how to extend the vocabulary or suppress a rule. It lints only the `.mdx` files the branch touched; `make lint-docs` checks every page. The `Check docs style` GitHub workflow runs that same script, so local and CI agree by construction. Every rule except `Infisical.EmDashes` reports at error level, including `Infisical.UIActions` (click/tap where the verb should be select) and `Infisical.Contractions`, so the run fails if either one reports a finding. `Infisical.EmDashes` reports at warning level and never changes the exit code -- read the printed output, not just the status. Note that Vale cannot see prose indented four or more spaces inside components -- roughly half of this repo -- and reports nothing about what it skipped, so a clean run does not mean a nested page was checked.\n\n### Auth & Permissions\n\nAuth modes (JWT, IDENTITY_ACCESS_TOKEN, SCIM_TOKEN) are extracted in `backend/src/server/plugins/auth/`. Authorization uses CASL (`@casl/ability`) with project-level and org-level permission checks — see `backend/CLAUDE.md` for backend details and `frontend/CLAUDE.md` for frontend permission hooks/HOCs. Note: `API_KEY` and `SERVICE_TOKEN` auth modes are deprecated — do not use them in new code.\n\n### Org-Scoped Products\n\nPAM and Agent Vault are products over one implicit project per organization, not projects a user creates. Their URLs are `/organizations/$orgId/<product>/…` with no `$projectId`, the frontend resolves the project from the org (`useImplicitProjectId`), the backend bootstraps it lazily and blocks generic create and delete, their roles are admin or member only, and each has its own metered identities dimension (`pam_identities`, `agent_vault_identities`). When you add a `ProjectType.PAM` arm anywhere, add the Agent Vault arm beside it. See `backend/src/ee/services/pam/CLAUDE.md` and `backend/src/ee/services/agent-vault/CLAUDE.md`.\n\n### Single-Project Products\n\nSecret Scanning has one active project per organization (the newest). Like Certificate Manager, its URLs are `/organizations/$orgId/secret-scanning/…` with no `$projectId`; legacy orgs can still hold several projects, so the frontend pins the one being viewed (see `frontend/CLAUDE.md`). New orgs and sub-orgs get it at creation (`bootstrapSecretScanningProject`), older orgs lazily from `GET /api/v2/secret-scanning/project`, and `projectId` is optional on its API (`inject-secret-scanning-project-id`). Do not add a project picker or a way to create more Secret Scanning projects.\n\n### Service Factory + Manual DI (Backend)\n\nNo IoC container in either backend. Every service is a factory function with explicit dependencies.\n- **Node.js**: Wired in `backend/src/server/routes/index.ts` — see `backend/CLAUDE.md`.\n- **Go**: Wired in `backend-go/internal/server/api/api.go` via `NewRegistry()` — see `backend-go/CLAUDE.md`.\n\n### Interface Pattern (Go)\n\nBoth handlers and services define narrow interfaces for their dependencies (consumer-defined interfaces). Only expose methods or fields that are needed — keep everything else private. This enables testability and loose coupling.\n\n### Alerting\n\nAll user-facing \"notify me when X happens\" features share one module: `backend/src/services/alert/`. It owns the alert CRUD, the channel stack (email, Slack, webhook, PagerDuty), recipients, dedup, history, and dispatch. To alert on a new resource, register an `IResourceAlertProvider` on the shared registry — do not stand up a per-domain alert service, channel table, or notification cron. See `backend/CLAUDE.md` for the provider contract and invariants.\n\n**If you touch a code path that deletes or detaches an alertable resource, it must reap that resource's alerts.** `alerts.resourceId` has no foreign key, so nothing cascades and the alert is left dangling. Use `alertService.deleteAlertsForDeletedResource` when the row is gone (unscoped, reaps across every org) and `deleteAlertsForResource` when the resource only left a scope. See the alerting invariants in `backend/CLAUDE.md`.\n\n### Approvals\n\nAll \"someone has to approve this before it happens\" features share one module:\n`backend/src/services/approval-policy/`, which owns policies, requests, steps, grants, break-glass, and\nthe generic `/v1/<policy-type>/approval-policies` endpoints. Current types: PAM access, certificate\nrequests, code signing.\n\nA policy type plugs in through **one** extension point, a `TApprovalResource` (see its doc comment),\nconstructed with its own dependencies and registered in the `approvalResources` map in\n`server/routes/index.ts`; that also gives it the generic `/v1/approval-policies/<type>/...` endpoints,\nwhich every type now uses for policy CRUD, requests, approve, reject and grant revocation. **Do not stand up a per-product approval service, request table, or review\nendpoint**: `access-approval-*` and `secret-approval-*` predate this module and are what we are\nconverging away from. A product-facing service on top of it (PAM's `pam-access-request-service.ts`) is\nfor product vocabulary only: resolving the resource a request names, notifications, and read models.\nAny rule about what is *allowed* belongs in the resource, or the product's own API and the shared API\nstop agreeing.\n\n### API Layer (Frontend)\n\nReact Query + Axios with query key factories per domain. Each API domain in `frontend/src/hooks/api/` has `queries.tsx`, `mutations.tsx`, and `types.tsx` — see `frontend/CLAUDE.md` for conventions.\n\n## Keeping CLAUDE.md Up to Date\n\nWhen making significant changes to the codebase (new services, architectural shifts, new patterns, major refactors), update the relevant CLAUDE.md file(s) with high-level findings. This includes this root file for cross-cutting concerns, `backend/CLAUDE.md` for Node.js backend changes, `backend-go/CLAUDE.md` for Go backend changes, and `frontend/CLAUDE.md` for frontend changes. The goal is to keep these files accurate as living documentation so future sessions start with correct context.\n\n## Wiring a New Full-Stack Feature\n\n1. **Backend**: Create service module, migration, wire DI, add routes — see checklist in `backend/CLAUDE.md`\n2. **Frontend**: Add API hooks in `src/hooks/api/<domain>/`, create page/view, wire route — see `frontend/CLAUDE.md` for routing and component patterns\n3. Check the backend work against [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md)\n4. Run `make reviewable-api` and `make reviewable-ui` before submitting\n\n## Helpful files\n\nClaude Code reads CLAUDE.md, not AGENTS.md, so the shared agent instructions are imported here rather than linked:\n\n@AGENTS.md\n","backend-go/CLAUDE.md":"# backend-go\n\nPartial Go rewrite of the Node.js backend using chi + oapi-codegen + raw pgx queries.\n\n## Commands\n\n```\nmake build            # build binary\nmake dev              # hot-reload via docker-compose + air\nmake test             # run all tests (unit + integration)\nmake test-unit        # unit tests only (./internal/...)\nmake test-integration # integration tests only (./tests/..., testcontainers)\nmake test-hsm         # HSM tests (requires Docker, builds test container)\nmake lint             # must pass before submission\nmake lint-fix         # auto-fix linting issues\nmake generate         # run go generate ./... for oapi-codegen\n```\n\n## Code Rules\n\n- **Linting**: `make lint` must pass. No `//nolint` without justification.\n- **Service constructor**: `(ctx context.Context, logger *slog.Logger, deps *Deps)`. Deps struct ends in `Deps`, passed by pointer. Every I/O method takes `ctx` first.\n- **Logger**: pass `*slog.Logger` via constructor — never `slog.Default()`.\n- **Interfaces**: consumer-defined. Accept interfaces, not concrete types.\n- **Visibility**: expose only what's needed.\n- **No DAL layer**: services use `pg.DB` directly with raw pgx.\n- **Error wrapping**: wrap DB errors at service level with context:\n  ```go\n  errutil.DatabaseErr(\"Failed to load\").WithErrf(\"FuncName(arg=%s): %w\", arg, err)\n  errutil.Forbidden(\"Access denied\").WithErrf(\"FuncName: permission check failed\")\n  ```\n- **Lean code**: inline single-use helpers.\n- **Test naming**: `Test<FunctionName>_<Scenario>`.\n\n## Architecture\n\n```\ncmd/infisical/main.go           # Entry point\ninternal/\n├── config/                     # Env config via koanf\n├── database/\n│   ├── pg/                     # DB interface (primary + replicas via pgxpool)\n│   │   ├── qb/                 # Query builders (Where, Insert, Update, Delete)\n│   │   ├── sqln/               # SQL nesting (GroupRows for LEFT JOIN flattening)\n│   │   └── pglock/             # PostgreSQL advisory locks\n│   └── redis/                  # Redis client\n├── libs/                       # crypto, errutil, fn, logutil\n├── server/\n│   ├── api/                    # Endpoint implementations + DI wiring\n│   │   ├── shared/             # Shared types (Error, ValidationError) + ErrorHandler\n│   │   ├── platform/           # Platform handlers (projects, etc.)\n│   │   └── secretmanager/      # Secret manager handlers\n│   └── middlewares/            # HTTP middlewares (RequireAuth, etc.)\n├── services/                   # Shared business logic (auth, permission, kms, ...)\n└── keystore/                   # Redis key-value operations\ntests/                          # Integration tests (external test packages)\n├── infra/                      # Test infrastructure (testcontainers, helpers)\n├── platform/                   # Platform service tests\n│   ├── auth/                   # Authentication handler tests\n│   ├── externalkms/            # External KMS (AWS/GCP) tests\n│   ├── hsm/                    # HSM tests (container-based)\n│   ├── kms/                    # KMS service tests\n│   ├── permission/             # Permission system tests\n│   ├── projects/               # Projects handler tests\n│   └── ratelimit/              # Rate limiting tests\n└── secretmanager/\n    └── secrets/                # Secrets API tests (list, get, permissions)\n```\n\n**Two tiers:**\n- `server/api/` — DI wiring + endpoint implementations. Handlers 1:1 with endpoints.\n- `services/` — Business logic, uses `pg.DB` directly.\n\n### Wiring (Composition Root)\n\nAll service/handler initialization lives in `server/api/`:\n\n```\nserver/api/\n├── api.go                         # Infra, Registry, NewRegistry()\n├── platform_services.go           # newPlatformServices() — kms, auth, permission, etc.\n├── secretmanager_services.go      # newSecretManagerServices() — secret, folder, import\n├── platform_routes.go             # RegisterPlatformRoutes() — chi routing\n├── secretmanager_routes.go        # RegisterSecretManagerRoutes() — chi routing\n├── shared/                        # Shared types + error handler\n├── platform/<handler>/            # Handler implementations\n└── secretmanager/<handler>/       # Handler implementations\n```\n\nInitialize as local variables first, then assign to struct fields.\n\n### Handler Package Structure\n\nEach handler package is generated from an OpenAPI spec using `oapi-codegen`:\n\n```\nserver/api/<product>/<handler>/\n├── openapi.yml    # OpenAPI 3.0 spec (source of truth)\n├── cfg.yaml       # oapi-codegen config\n├── gen.go         # GENERATED types, HTTPAdapter, ServiceInterface\n├── <name>.go      # Handler struct + NewHandler() + //go:generate directive\n└── <endpoint>.go  # One handler method per file (list_secrets.go, get_secret.go, ...)\n```\n\n### Handler Pattern\n\nUses `oapi-codegen` (DoorDash fork) to generate types and HTTP adapters from OpenAPI specs:\n\n**`openapi.yml`** — OpenAPI 3.0 source of truth:\n```yaml\npaths:\n  /api/v4/secrets:\n    get:\n      operationId: ListSecretsV4\n      summary: List secrets\n      parameters:\n        - name: projectId\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: environment\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: secretPath\n          in: query\n          schema:\n            type: string\n      responses:\n        '200':\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ListSecretsV4Response'\n```\n\n**`<handler>.go`** — Handler implements generated `ServiceInterface`:\n```go\n//go:generate go tool oapi-codegen -config cfg.yaml openapi.yml\n\npackage secret\n\nvar _ ServiceInterface = (*Handler)(nil)\n\ntype Handler struct {\n    logger     *slog.Logger\n    permission PermissionService\n    secrets    SecretsService\n}\n\nfunc NewHandler(deps *Deps) *Handler {\n    return &Handler{...}\n}\n\n// ListSecretsV4 implements ServiceInterface\nfunc (h *Handler) ListSecretsV4(ctx context.Context, opts *ListSecretsV4ServiceRequestOptions) (*ListSecretsV4ResponseData, error) {\n    q := opts.Query\n    secretPath := fn.ValueOr(q.SecretPath, \"/\")  // Use helper for optional fields\n    \n    // ... business logic ...\n    \n    return NewListSecretsV4ResponseData(&ListSecretsV4Response{\n        Secrets: secrets,\n    }), nil\n}\n```\n\n**Generated types:**\n- Required fields: plain types (`string`, `int`, etc.)\n- Optional fields: pointers (`*string`, `*int`, etc.)\n- Use `fn.ValueOr(ptr, default)` for safe access with fallback\n- Use `new(value)` to create pointers for optional fields (Go 1.26+)\n\n**Route registration** (`<product>_routes.go`):\n```go\nfunc RegisterSecretManagerRoutes(router chi.Router, logger *slog.Logger, platform *PlatformServices, svc *SecretManagerServices) {\n    secretsHandler := secret.NewHandler(&secret.Deps{...})\n    secretsAdapter := secret.NewHTTPAdapter(secretsHandler, shared.NewErrorHandler(logger))\n\n    router.Group(func(r chi.Router) {\n        r.Use(middlewares.RequireAuth(\n            platform.Authenticator,\n            middlewares.JWTAuth,\n            middlewares.IdentityAccessTokenAuth,\n            middlewares.ServiceTokenAuth,\n        ))\n        r.Get(\"/api/v4/secrets\", secretsAdapter.ListSecretsV4)\n        r.Get(\"/api/v4/secrets/{secretName}\", secretsAdapter.GetSecretByNameV4)\n    })\n}\n```\n\n### Auth Middleware\n\n`RequireAuth` is a fail-closed middleware that validates bearer tokens:\n\n```go\nr.Use(middlewares.RequireAuth(\n    authenticator,\n    middlewares.JWTAuth,              // User session JWT\n    middlewares.IdentityAccessTokenAuth,  // Machine identity token\n    middlewares.ServiceTokenAuth,     // Service token\n))\n```\n\n- No bearer header → 401\n- Bearer present but mode not in allowed list → 401\n- Validator returns error → 401\n- Success: identity stored in context via `auth.WithIdentity()`\n\n### Error Handling\n\n`shared.ErrorHandler` translates errors to HTTP responses:\n- `errutil.Error` → appropriate status + JSON body\n- `runtime.ValidationErrors` → 400 with field errors\n- Unknown errors → 500\n\nAll errors include request ID and are logged appropriately (warn for 4xx, error for 5xx).\n\n### Handler vs Service Responsibilities\n\n**Handlers** (`server/api/`) — thin orchestration:\n1. Extract identity from context\n2. Resolve permissions + IDs (e.g., project ID from slug) via shared services\n3. Call domain service with opts (including `AccessChecker` for permission filtering)\n4. Build API response + audit logs\n\n**Services** (`services/`) — domain logic: data fetching/aggregation, decryption/encryption, business rules (import chaining, secret expansion, personal overrides), and permission filtering via `AccessChecker` in opts.\n\n**AccessChecker pattern**: services accept an optional `AccessChecker` interface in opts. Pass `nil` to skip permission checks (internal/integration use). Permission logic stays in handlers; services enforce it.\n\n### Permission Checker Pattern\n\nPermission checks use `gocasl`. Instead of calling `gocasl.Can()` directly in handlers/services, wrap each domain's checks in a dedicated checker type under `services/permission/<product>/`:\n\n```go\n// services/permission/project/secret_permission.go\ntype SecretAccessChecker struct {\n    ability *gocasl.Ability\n}\n\nfunc NewSecretAccessChecker(ability *gocasl.Ability) *SecretAccessChecker {\n    return &SecretAccessChecker{ability: ability}\n}\n\nfunc (c *SecretAccessChecker) CanReadSecretValue(env, path, key string, tags []string) bool {\n    return gocasl.Can(c.ability, project.SecretActionReadValue, project.SecretSubject{\n        Environment: env, SecretPath: path, SecretName: key, SecretTags: tags,\n    })\n}\n```\n\nUsage:\n\n```go\npermResult, _ := permissionSvc.GetProjectPermission(ctx, args)\nchecker := project.NewSecretAccessChecker(permResult.Permission.Ability)\nif !checker.CanReadSecretValue(env, path, key, tags) {\n    return errutil.Forbidden(\"Cannot read secret\")\n}\n```\n\n**Why**: keeps subject construction and action selection in one place per domain, makes checkers easy to mock in tests, and prevents handler/service code from depending on `gocasl` directly. Existing checkers: `SecretAccessChecker`, `AssumePrivilegeChecker`.\n\nActions and subjects live in `services/permission/project/{actions.go,subjects.go}`:\n- `gocasl.DefineAction[SubjectType](\"verb\")` — typed action constants\n- Subject structs implement `SubjectType() string` and `GetField(string) any` so CASL can match field-conditional rules\n\n**Database access**: services receive `pg.DB` and execute raw pgx queries via helper packages. Use `db.Primary()` for writes, `db.Replica()` for reads.\n\n### Query Helpers\n\n**`qb`** — dynamic SQL builders:\n\n```go\n// WHERE (named args)\nargs := pgx.NamedArgs{\"folderID\": folderID}\nwhere := qb.NewWhere().\n    Add(\"folder_id = @folderID\").\n    AddIf(len(keys) > 0, \"key = ANY(@keys)\")\nargs[\"keys\"] = keys\nrows, _ := db.Replica().Query(ctx, `SELECT * FROM secrets WHERE `+where.String(), args)\n\n// INSERT / UPDATE / DELETE\nsql, args := qb.Insert(\"secrets\", \"id\", \"key\", \"folder_id\").\n    Values(id1, key1, folder1).Values(id2, key2, folder2).Returning(\"*\").Build()\nsql, args := qb.Update(\"secrets\").Set(\"key\", newKey).Where(\"id = @id\", \"id\", id).Returning(\"*\").Build()\nsql, args := qb.Delete(\"secrets\").Where(\"id = @id\", \"id\", id).Build()\n```\n\n**`sqln`** — flatten LEFT JOIN rows into nested structs:\n\n```go\nsecrets := sqln.GroupRows(flatSecrets, sqln.Grouper[Secret, uuid.UUID]{\n    Key:   func(s *Secret) uuid.UUID { return s.ID },\n    Merge: func(existing, row *Secret) {\n        if len(row.Tags) > 0 {\n            existing.Tags = fn.AppendUnique(existing.Tags, row.Tags[0],\n                func(t Tag) uuid.UUID { return t.ID })\n        }\n    },\n})\n```\n\n**`pglock`** — PostgreSQL advisory locks:\n\n```go\ntx, _ := db.Primary().Begin(ctx)\nlock, err := pglock.AcquireBlockingLock(ctx, tx, \"my_lock_id\")\nif err != nil { tx.Rollback(ctx); return err }\ndefer lock.Rollback(ctx)  // safety net\n// ... work ...\nlock.Release(ctx)         // commits\n```\n\n### Writing Raw Queries\n\nUse `pgx.NamedArgs`. Table aliases should be readable abbreviations (`memberships m`, `membership_roles mr`, `additional_privileges ap`) — never single letters.\n\n```go\nrows, _ := db.Replica().Query(ctx, `\n    SELECT id, key, encrypted_value\n    FROM secrets_v2\n    WHERE folder_id = @folderID AND user_id IS NULL\n    ORDER BY key ASC\n`, pgx.NamedArgs{\"folderID\": folderID})\nsecrets, _ := pgx.CollectRows(rows, pgx.RowToStructByName[Secret])\n```\n\n## Wiring a New Feature\n\n1. **Service** in `services/<product>/<name>/` with interface-based deps.\n\n2. **Handler** in `server/api/<product>/<name>/`:\n   - Create `openapi.yml` with endpoints\n   - Create `cfg.yaml` from template (see existing handlers)\n   - Run `go generate ./...` to create `gen.go`\n   - Create `<handler>.go` with `//go:generate` directive, `Handler` struct, `NewHandler()`\n   - Implement `ServiceInterface` methods (one file per endpoint)\n\n3. **Permission checks** (if needed): add action constants in `services/permission/<product>/actions.go`, subject struct in `subjects.go`, and a checker in `<domain>_permission.go`.\n\n4. **Wire routes** in `server/api/<product>_routes.go`:\n   - Create handler and HTTP adapter\n   - Mount routes with appropriate auth middleware\n\n5. **Register** service in `server/api/<product>_services.go`.\n\n6. Run `make test && make lint`.\n",".agents/skills/testing-secrets-ui/SKILL.md":"---\nname: testing-secrets-ui\ndescription: Test the Infisical secrets management UI end-to-end. Use when verifying secret CRUD operations, URL encoding fixes, or frontend API layer changes.\n---\n\n# Testing Secrets Management UI\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- The repo cloned locally\n\n## Setting Up Local Dev Environment\n\n1. Start the full stack:\n   ```bash\n   make up-dev\n   ```\n   This runs `docker-compose.dev.yml` which starts PostgreSQL, Redis, backend (Fastify), frontend (React/Vite), and Nginx.\n\n2. Wait for all containers to be healthy. The app runs on `http://localhost:8080`.\n\n3. **Common issue: Migration lock**\n   If the backend fails to start with \"Migration table is already locked\", unlock it:\n   ```bash\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_lock SET is_locked = 0;\"\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_startup_lock SET is_locked = 0;\"\n   docker restart infisical-dev-api\n   ```\n   Wait ~15 seconds for migrations to complete.\n\n4. Create a test account at `http://localhost:8080/signup` (local dev has no email verification).\n\n5. Create a test organization and project after signup.\n\n## Testing Secret CRUD Operations\n\n### Create Secret\n- Navigate to a project's Secret Dashboard (Project Overview page)\n- Click \"+ Add Secret\" to open the Create Secret modal\n- Fill in Environment (defaults to Development), Key, and Value\n- Click \"Create Secret\"\n- The modal uses the `createSecret` function from `frontend/src/hooks/api/secrets/mutations.tsx`\n\n### Update Secret (Inline Edit)\n- On the dashboard, click the secret's key field to edit it inline\n- Change the key or value\n- Click the save icon (floppy disk) that appears\n- This uses the `useUpdateSecretV3` mutation from `mutations.tsx`\n\n### Testing URL Encoding\nWhen testing that special characters in secret keys are handled correctly:\n- Try creating/updating secrets with `/`, `%`, `#`, `?`, `&` in the key name\n- Verify the browser network tab shows the key is URL-encoded (e.g., `/` becomes `%2F`)\n- Check backend logs: `docker logs infisical-dev-api 2>&1 | grep \"secret\"` to confirm the encoded URL\n- The error should be a \"Validation Error\" (Fastify param validation), NOT \"Route not found\"\n\n## Key Files\n\n- `frontend/src/hooks/api/secrets/mutations.tsx` — Secret create/update/delete mutations\n- `frontend/src/hooks/api/secrets/queries.tsx` — Secret query hooks\n- Backend logs: `docker logs infisical-dev-api`\n- Database: `docker exec -it infisical-dev-db psql -U infisical -d infisical`\n\n## Devin Secrets Needed\n\nNo external secrets are required for local dev testing. The local environment uses:\n- Database: PostgreSQL with user `infisical` (configured in docker-compose.dev.yml)\n- All services run locally via Docker\n","tests/CLAUDE.md":"# CLAUDE.md\n\nThe Go blackbox suite. It boots a real Infisical in Docker and calls it over HTTP only.\nIts contract with the server is a Docker image, environment variables, and the OpenAPI\nspec, so the same tests run against any implementation of that contract.\n\n## Read next\n\n| Task | Read |\n|---|---|\n| Writing or reviewing a test | [docs/writing-tests.md](docs/writing-tests.md) |\n| Using or adding a fake third party, App Connection, or mail | [docs/fakes.md](docs/fakes.md) |\n| Calling an endpoint the client does not have yet | [docs/client.md](docs/client.md) |\n| A failing, flaky, or CI-only test | [docs/debugging.md](docs/debugging.md) |\n\n## What belongs here\n\nAnything a customer or API client can observe: responses, status codes, error messages,\nmail, calls to third parties, and effects of queues or crons after the response.\n\nNot here:\n\n- Pure functions and arithmetic. Unit test them in `backend/`.\n- Anything needing direct access to Infisical's own Postgres or Redis, an internal\n  service handle, or a mocked module. Those belong in `backend/e2e-test/`.\n- Exhaustive input validation. Cover representative cases here; matrices go in unit\n  tests.\n\n## Commands\n\n```bash\nmake test-suites   # product tests; starts and stops the stack\nmake test-harness  # harness tests\nmake test-all      # both\nmake test-unit     # no containers\nmake lint          # gofmt, vet, staticcheck\n```\n\nTo iterate on one package, keep the stack up:\n\n```bash\nmake up\ngo test ./suites/secretmanager/secrets/... -count=1\nmake down\n```\n\n`-count=1` disables Go's result cache. Run `make down` after changing a container's\nconfiguration, or the old container is reused.\n\n## Layout\n\n```\nsuites/             product behaviour, grouped by product (suites/secretmanager/secrets)\nharnesstest/        harness behaviour; only for failures the harness alone can cause\nharness/            Stack, Profile, Tenant, Principal\nfixture/            platform resources: projects, App Connections\nfixture/<product>/  one package per product: secretmanager, pki, pam\nfixture/sso/        SAML and OIDC logins, for any suite whose claim needs an SSO session\nfakes/              one package per external provider\ninternal/           wait, mail, id, apierr, spec\nclients/api/        generated client; never edit by hand\n```\n\n- Platform fixtures live in `fixture`; each product's fixtures live in\n  `fixture/<product>`. A product owns its nouns: a secret in Secret Manager is not a\n  secret in Cert Manager. Within a package, add a file per resource.\n- Helpers start in the test package and move to a fixture on the second caller.\n- Imports go one way: `fixture` may import `harness` and `fakes`; `harness` never imports\n  a fixture; `fakes` import neither.\n\n## Rules\n\n- `t.Parallel()` on every test and subtest under `Shared`; never under `Isolated`.\n- Every test body is `// Setup`, `// Action`, `// Assert`.\n- Assert with `testify/require` only.\n- Every test creates its own tenant.\n- Never build an API client by hand; use a principal's `.API` or `tn.Client`.\n- Never sleep. Wait on the status the product records, then assert the outcome.\n- Test plumbing once, not per provider.\n- A rejection test also checks that nothing changed.\n- Use the actor the claim is about, not `tn.Admin` by default.\n- No package, fixture, or option for a single caller.\n\n## Before you call it done\n\n```bash\nmake lint\nmake test-all\nmake up && go test -p 4 ./suites/... ./harnesstest/... -count=3; make down\n```\n\nThe last command is the flake gate. A failure there is shared state leaking across\ntenants. Then break the new assertion, and what it depends on, and confirm both fail.\n","e2e/CLAUDE.md":"# CLAUDE.md\n\nGuidance for Claude Code sessions working in this directory. Covers both the operator-facing view (env vars, bootstrap, how to run) and the agent-facing context (architecture, gotchas, file layout).\n\n## What this directory is\n\nExternal Playwright suite targeting a deployed Infisical environment (gamma). Runs in CI between `gamma-deployment` and any prod-deploy job — defined as `gamma-playwright-e2e` in the `infrastructure` repo's `.github/workflows/infisical-deployment.yml`. A red run blocks all 11 prod/dedicated deploy jobs via `needs:`.\n\n**Not** the same as `backend/e2e-test/`, which is in-process Vitest with a bootstrapped server. That one tests the backend in isolation; this one tests the deployed product as a black box.\n\n## Scope discipline\n\nThis suite exists for **smoke-level** verification — \"does the surface still work end-to-end against real infrastructure.\" If a test is best expressed as \"given these inputs to a function, assert these outputs,\" it belongs in a unit or integration test, not here. The CI cost per test (minutes, not seconds) makes narrow coverage expensive.\n\nConcrete rule: a test here should be *useful even if every integration test passed*. It's catching things integration tests can't — config drift, deploy regressions, end-to-end auth flows that depend on real IdPs.\n\n## Architecture\n\n- **Playwright** — TypeScript, Chromium project only. Single worker, `fullyParallel: false`. The single-worker constraint is load-bearing — see \"Serial execution\" below.\n- **Two-token CI surface.** Only the two real secrets (`E2E_SCIM_TOKEN`, `E2E_IDP_ADMIN_TOKEN`) come from env vars — validated fail-fast in [`helpers/env.ts`](helpers/env.ts). Deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config row id) are constants in [`helpers/constants.ts`](helpers/constants.ts). The deliberate tradeoff: re-seeding gamma's e2e org rotates the `saml_configs.id`, which then has to be updated in code instead of in a GH Actions secret.\n- **Twingate-gated.** Gamma sits behind Twingate; the CI workflow installs the Twingate connector before tests run. Locally, you need to be on Twingate too. The Playwright Chromium inherits routes from the host network connector.\n- **Mock IdP we control.** SAML SSO points at `preview-orchestrator.infisical.workers.dev/saml-idp/*` — Hono routes on the existing `preview-environments` Worker, using `samlify` for signing under `nodejs_compat`. No SaaS account, no vendor DOM. The test calls `POST /saml-idp/identity` with a per-run NameID, sets the returned session id as a cookie on the Worker domain, then navigates to gamma's SAML start URL; the browser carries the cookie through gamma's 302 to the IdP, which mints a signed assertion for that identity. Cert is committed at [`fixtures/idp-cert.pem`](fixtures/idp-cert.pem); private key is in CF Secrets only (`SAML_IDP_PRIVATE_KEY`). Worker code lives in the `preview-environments` repo, not here — that side of the system is owned separately.\n- **Two SAML entry shapes, one spec file.** `tests/scim-saml-auth.spec.ts` covers both variants under one `describe`. SP-initiated navigates to gamma's `/api/v1/sso/redirect/saml2/organizations/:orgSlug` first; gamma 302s the browser to the Worker's `/saml-idp/sso` with an `AuthnRequest`. IdP-initiated starts at the Worker's `/saml-idp/initiate`, which mints an *unsolicited* signed `SAMLResponse` (no `InResponseTo` per SAML 2.0 §3.4.1.5) and auto-POSTs it directly to gamma's ACS at `/api/v1/sso/saml2/:samlConfigId`. The IdP-initiated test passes the gamma ACS URL through `POST /identity` (validated against the gamma SP origin allowlist), reachable via `gammaSamlAcsUrl()` in [`helpers/idp-client.ts`](helpers/idp-client.ts).\n\n## Load-bearing identity coupling\n\nThis is the one detail that, if you get it wrong, makes the test silently fail in confusing ways:\n\n```\nSCIM userName  ==  SAML NameID emitted by the IdP  ==  UserAlias.externalId\n```\n\nThe chain works because:\n- SCIM provisioning creates `UserAlias.externalId = req.body.userName` (backend `scim-router.ts:266` → `scim-service.ts:499`).\n- Org auth method is SAML, so the alias type is SAML (`scim-service.ts:428`).\n- SAML login looks up by `userAliasDAL.findOne({ externalId: profile.nameID, orgId, aliasType: SAML })` (`saml-config-service.ts:535`).\n\nWith the in-house mock IdP, the test now **owns this value end-to-end**: it generates a per-run `EXTERNAL_ID` (`e2e-${runId}@infisical-e2e.test`) and passes the same string as the SCIM `userName`, the IdP `email` (which samlify uses as the SAML `NameID`), and implicitly as `UserAlias.externalId`. There's no SaaS-side configuration to drift. If `tests/scim-saml-auth.spec.ts` and `helpers/idp-client.ts` agree on the value, the chain holds.\n\nThe current test still ends at `/signup/sso?token=…` rather than `/login/select-organization` — gamma routes any `userAlias.isEmailVerified = false` user through `SIGNUP_REQUIRED` (`auth-login-service.ts:322`), and SCIM hardcodes that field to `false` (`scim-service.ts:506`). The signup token JWT contains the verified identity fields. Patching the SCIM service to mark aliases verified at provisioning time would flip the redirect back to the original `/login/select-organization`; that's a separate backend change.\n\n## Serial execution is intentional\n\nTests run with a single worker. The old reason (single static IdP identity) is gone — the mock IdP can mint an assertion for any NameID the test asks for, so per-run unique externalIds already work.\n\nWhat still binds us to single-worker is the **shared gamma org**: there's one `saml_configs` row, one set of `email_domains` rows, one `orgAuthMethod`. Concurrent tests writing to the same org's `user_aliases`/`memberships` rows risk subtle interleavings (e.g. the same email landing on a fallback `userDAL.findOne({ username })` from two flows at once). Unblocking parallelism would mean either per-test gamma orgs (heavier seed script) or accepting that risk for tests that don't touch shared org state.\n\nIf you do enable parallel workers, also ensure the IdP `/saml-idp/identity` flow's cookie scoping survives — the Worker sets `path=/saml-idp`, so multiple concurrent tests can each set their own session cookie value on the Worker origin without collision (Playwright contexts are already isolated per test).\n\n## File layout\n\n| Path | Purpose | Touch when... |\n|---|---|---|\n| `playwright.config.ts` | Runner config — baseURL, retries, reporter | Adding a project (e.g. Firefox), changing retry policy |\n| `helpers/env.ts` | Fail-fast env loader for the two bearer-token secrets | Adding a new *secret* (also update `.env.example` and the CI workflow) — non-secret config goes in `constants.ts` instead |\n| `helpers/constants.ts` | Hardcoded deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config id) | Gamma's e2e org is re-bootstrapped and `saml_configs.id` rotates — update `SAML_CONFIG_ID`; the seed script logs the new value on the `[seed] E2E_SAML_CONFIG_ID = <uuid>` line |\n| `helpers/scim.ts` | SCIM CRUD wrappers, reusable across tests | Any new SCIM operation a test needs (e.g. Groups) |\n| `helpers/idp-client.ts` | Talks to the mock IdP — stashes a NameID (+ optional ACS URL for IdP-initiated), sets the Worker-domain session cookie, and starts either SP- or IdP-initiated flows | Adding a new SAML-flavored flow, or if the Worker IdP's API/cookie scheme changes |\n| `tests/*.spec.ts` | One test per flow | Adding a new flow (see below) |\n| `fixtures/idp-cert.pem` | Committed public cert for the mock IdP; gamma's SAML config row holds this same cert (KMS-encrypted) | Rotating the IdP keypair (see [`fixtures/README.md`](fixtures/README.md)) |\n| `tsconfig.json` | TS config — no `baseUrl`/`paths` on purpose, relative imports only | Avoid unless you have a real reason |\n\n## Adding a new test\n\n1. **One *flow* per file**, not one test per file. A flow is a distinct end-to-end path being verified (e.g. \"SCIM-provisioned user logs in via SAML\", \"SAML response rejection\", \"SCIM deactivation revokes SAML login\"). Variants of the *same* flow that differ only in entry shape or input (SP-initiated vs IdP-initiated SAML; each strictness gate within a rejection suite) belong in the same file under one `test.describe`, as multiple `test()` blocks. Distinct flows get their own file.\n2. Name the file after the flow (`scim-saml-auth.spec.ts`, `saml-rejection.spec.ts`, `scim-deactivation.spec.ts`). Per-variant naming goes inside the file as test titles.\n3. Import from `../helpers/constants`, `../helpers/env`, `../helpers/scim`, `../helpers/idp-client`. Add a new helper module only if a fundamentally new flow needs shared setup (e.g. an OIDC helper if/when that's covered).\n4. **Per-variant state goes in `beforeEach` / `afterEach`**, not `beforeAll` / `afterAll`. Each variant test should own its own SCIM user (fresh `externalId`) so a mid-test crash on one variant doesn't poison the next. See `saml-rejection.spec.ts` for the pattern. Use `beforeAll`/`afterAll` only when there's genuinely shared, idempotent state across the whole file.\n5. **Always clean up.** `beforeEach` defensively deletes any state the test creates; `afterEach` deletes what the test produced. Tests crash mid-flow sometimes; the e2e org accumulates rows otherwise. Note: the SAML signup branch also creates a fresh user row on each run — currently leaks; revisit when SCIM's `isEmailVerified` default is patched.\n6. **One assertion-block per test should be enough** to claim the variant works. Factor a shared `expect…()` helper inside the file when multiple tests assert the same outcome shape (e.g. `expectSignupSsoRedirectFor(page, email)` in `scim-saml-auth.spec.ts`) — that's preferable to copying ~10 lines of expect calls across tests. Don't expand a smoke test into a unit-test-style assertion sprawl — push fine-grained detail into integration tests.\n7. **Don't share helpers across spec files.** Helpers live in `helpers/*` or as file-local consts/functions at the top of the spec. Cross-spec imports create CI debugging cliffs — avoid.\n8. Run locally first (`npm test`) with `.env` populated. CI will replicate via GH Actions secrets.\n\n## What you can break by accident\n\nThese external dependencies have to stay in sync — if you change the backend or the Worker, the test breaks silently. Worth grepping for after any change in these areas:\n\n- **SCIM endpoint URL** (`backend/src/ee/routes/v1/scim-router.ts`, mounted at `/api/v1/scim`). `helpers/scim.ts` hardcodes `/api/v1/scim/Users` and `/api/v1/scim/Users/:id`. If the router prefix moves, update here.\n- **SAML start/ACS URLs** (`backend/src/ee/routes/v1/saml-router.ts`). `helpers/idp-client.ts` hardcodes `/api/v1/sso/redirect/saml2/organizations/:orgSlug` (SP-initiated) and `/api/v1/sso/saml2/:samlConfigId` (IdP-initiated ACS, built from `SAML_CONFIG_ID` in `helpers/constants.ts`). If either path moves, both consumer sites need to follow.\n- **Post-login redirect** (`saml-config-service.ts` → `auth-login-service.ts:322`). `tests/scim-saml-auth.spec.ts` asserts `/signup/sso?token=…`; if SCIM gets patched to mark aliases verified, the redirect flips to `/login/select-organization` and the assertion needs to follow.\n- **NameID-to-externalId mapping** (`saml-config-service.ts:535`). Currently `profile.nameID`. If the matching attribute changes, the identity-coupling section above becomes stale.\n- **`scimEnabled` / `orgAuthMethod` semantics** (`scim-service.ts:413, 419`). If new gating gets added (e.g. additional flags), the seed script in `backend/scripts/seed-gamma-fixtures.ts` needs to set them.\n- **Verified email domain check** (`email-domain-fns.ts:11`). SCIM POST and SAML login both require the email domain to be on the org's `email_domains` allowlist with `status='verified'`. The seed script writes one for `infisical-e2e.test`; if the test ever uses a different domain, add a row.\n- **Mock IdP cert ↔ gamma SAML config cert** must match. Seed script reads the committed cert from `e2e/fixtures/idp-cert.pem` and KMS-encrypts it into the `saml_configs` row; the Worker signs with the matching private key from `SAML_IDP_PRIVATE_KEY` (CF Secret). Rotating one requires rotating the other — see `fixtures/README.md`.\n- **passport-saml strictness** (`saml-router.ts:51-117`). Under `authProvider = keycloak-saml` (what the seed script writes), no overrides apply — passport-saml requires both Response and Assertion signed, attribute `email`/`firstName`/`lastName` present, and audience matching `appCfg.SITE_URL`. `validateInResponseTo` defaults to `\"never\"` in `@node-saml/node-saml` 5.x, which is why the IdP-initiated path works at all; flipping it to `\"ifPresent\"` or `\"always\"` would break IdP-initiated first. The Worker's unsolicited template omits `InResponseTo` entirely (per SAML 2.0 §3.4.1.5) so the test stays valid if that default is ever tightened. Changing the seed's `authProvider` value would change the strictness profile.\n\n## Common pitfalls\n\n- **Diagnostics about `@playwright/test`** before `npm install` runs — expected. Resolve by `cd e2e && npm install`.\n- **The seed script's bootstrap is one-pass now.** Direct DB writes don't trigger `updateSamlCfg`'s `scimEnabled=false` side effect, so a single run leaves a usable state. (The old SSOJet-era two-pass requirement is gone.) Re-run is still safe; it's idempotent.\n- **CF Access bypass for `/saml-idp/*`** must be in place on the `preview-orchestrator` Worker, otherwise every request to the mock IdP 302s to the CF Access login page. The bypass is safe because the IdP routes have their own auth (`Authorization: Bearer SAML_IDP_ADMIN_TOKEN` on `/identity`, cookie + KV lookup on `/sso` and `/initiate`, intentionally public on `/metadata.xml`).\n- **Worker changes ship via `wrangler deploy`**, not the gamma deploy. After editing Worker code (`/saml-idp/*` routes, IdP templates), deploy from the `preview-environments` repo before running the test or it'll hit the previous version. `tsc` passing here doesn't prove the Worker is up to date.\n- **SCIM POST + SAML signup leak a user row per test run** because the SAML signup branch creates a fresh `users` row even though SCIM already created one. SCIM `DELETE /Users/:id` removes the membership but not the orphan user. Tractable but accumulates; revisit if it becomes noisy.\n- **Don't add a `.npmrc` here for the 7-day minimum-release-age rule** — `e2e/` is a test harness, not a runtime artifact. The supply-chain concern that motivates the rule in `backend/`/`frontend/` doesn't apply.\n- **CI uses `npm ci`** against the committed `package-lock.json` for determinism. If you change deps, regenerate the lockfile (`npm install`) and commit it in the same change, otherwise `npm ci` fails the prod gate.\n- **Don't widen what the seed script creates** unless you've thought through the safety. It already refuses prod (host/dbname regex + `INFISICAL_ENV=prod` + interactive confirmation). Adding more powerful operations means re-evaluating the guard surface.\n\n## Where context lives\n\n- CI workflow: `infrastructure` repo, `.github/workflows/infisical-deployment.yml` (the `gamma-playwright-e2e` job)\n- Seed script: [`../backend/scripts/seed-gamma-fixtures.ts`](../backend/scripts/seed-gamma-fixtures.ts)\n- Backend SCIM service: `../backend/src/ee/services/scim/`\n- Backend SAML service: `../backend/src/ee/services/saml-config/`\n- Mock IdP Worker code: `preview-environments` repo, `src/saml-idp/` (separate repo — owned independently)\n- IdP keypair provenance + rotation: [`fixtures/README.md`](fixtures/README.md)\n- Root architectural notes: [`../CLAUDE.md`](../CLAUDE.md), [`../backend/CLAUDE.md`](../backend/CLAUDE.md)\n","frontend/CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **frontend** package of the Infisical monorepo — a React 19 SPA built with Vite 8, TanStack Router, React Query, and Tailwind CSS v4.\n\n## Essential Commands\n\nAll commands run from the `frontend/` directory:\n\n- `npm run dev` — start dev server on port 3000\n- `npm run build` — TypeScript check + Vite production build\n- `npm run lint:fix` — ESLint autofix (double quotes, simple-import-sort with 7 groups)\n- `npm run type:check` — TypeScript type check only (`tsconfig.app.json`)\n- `make reviewable-ui` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n- `npm run storybook` — Storybook on port 6006\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## Architecture Overview\n\n### Routing (TanStack Router v1)\n\nVirtual file routes defined in `src/routes.ts` using a functional DSL (`route()`, `index()`, `layout()`, `middleware()`). The generated route tree is in `src/routeTree.gen.ts` (do not edit manually — regenerated on dev server start or when `src/routes.ts` changes).\n\nEach page directory has a `route.tsx` that defines its route:\n```tsx\ncreateFileRoute(path)({ component, validateSearch: zodValidator(schema), beforeLoad })\n```\n\nRoute middleware chain: root → `authenticate` → `inject-org-details` → org-layout → product-specific layouts (secret-manager-layout, cert-manager-layout, kms-layout, etc.).\n\nMiddleware pages in `src/pages/middlewares/`: `authenticate.tsx` (auth guard + redirect), `inject-org-details.tsx` (org context), `restrict-login-signup.tsx` (prevents auth pages when logged in).\n\n### Pages / Views / Components Hierarchy\n\n- **`src/pages/`** — Route-level components organized by product feature (secret-manager, cert-manager, kms, pam, secret-scanning, organization, project, admin, auth). Each has `route.tsx` + page component + local `components/`.\n- **`src/views/`** — Reusable page-level UI composed into multiple pages. Pages import views with configuration props.\n- **`src/components/v3/`** — Latest shared UI component library (preferred). Contains `generic/` (Accordion, Alert, Button, Dialog, Select, Table, etc.) and `platform/` (domain-specific components such as `PageHeader`, scope icons, `ProjectPermissionSubjects`, and `SecretManagerResources`). **Always use v3 components for new code.** Only use v2 components when a v3 equivalent does not exist. Policy group tiles look up `getProjectPermissionSubjectPresentation`. Secret Manager overview resources (name/slug/icon/color) come from `SecretManagerResources`, which points at that subject catalog.\n- **`src/components/v2/`** — Legacy shared UI components built on Radix UI primitives + Tailwind. Uses `cva` (class-variance-authority) for variants and `tailwind-merge` for class conflict resolution. Being superseded by v3 — do not use for new features if a v3 alternative exists.\n\n### API Layer (React Query + Axios)\n\nEach API domain in `src/hooks/api/` (100+ domain folders) follows this structure:\n- **`queries.tsx`** — Query key factory pattern: keys return `[{ params }, \"domain-label\"]`. Export named query hooks (`useGetSecrets`, etc.) and raw fetch functions.\n- **`mutations.tsx`** — `useMutation` hooks that invalidate relevant query keys on success via `queryClient.invalidateQueries()`.\n- **`types.tsx`** — Request/response DTOs.\n\nHTTP client in `src/config/request.ts`: Axios instance with automatic token injection and 401/403 interceptors.\n\n#### React Query Global Defaults (`src/hooks/api/reactQuery.tsx`)\n\nThe `QueryClient` sets these global defaults for all queries:\n- **`staleTime: 60_000`** (60 seconds) — data fetched within the last 60s is considered fresh and won't be refetched on component mount/remount. This prevents redundant API calls during normal page navigation. Queries that need real-time data (e.g., identity auth configs, dynamic secret leases) override this with `staleTime: 0`.\n- **`refetchOnWindowFocus: false`** — queries do not refetch when the browser tab regains focus.\n- **`retry: 1`** — failed queries retry once.\n\nMutation failures are reported globally by `MutationCache.onError` using the server error message. Do not add another error `createNotification` in a mutation `catch` or `onError`, because it produces duplicate toasts. Local error handling may still restore optimistic state, keep a dialog open, or perform other control flow without displaying a second notification.\n\nWhen adding new queries, consider whether the default 60s staleTime is appropriate:\n- For data that changes only on explicit user action (secrets, folders, org metadata): the 60s default is fine or could be longer.\n- For data that must always be fresh (auth configs, lease TTLs): override with `staleTime: 0, gcTime: 0`.\n- For rarely-changing data (server config, user profile): use `staleTime: Infinity` as the context providers do.\n\nOrganization switching resets the query cache centrally in `pages/middlewares/inject-org-details.tsx`, after authentication succeeds and before tenant data loads. `hooks/api/organization/cache.ts` tracks the active organization per `QueryClient` and removes all queries except the exact auth-token, user-profile, server-config, and session-announcement keys. Removing queries also cancels pending query results, including queries whose keys omit the organization. Same-organization navigation keeps the cache, and the organization layout remounts on organization changes to reset local UI state. Keep tenant cache cleanup at this boundary rather than adding feature-specific removal lists to individual organization selectors. Logout paths must use `clearSession` to remove user-specific cache entries while preserving public server configuration for the login page.\n\nOrganization switchers use `helpers/organizationSwitch.ts` to retain safe sections and tabs while dropping resource IDs, action parameters, and legacy suborganization search state. Pass the current location through `navigateUserToOrg` only for organization switches; initial login keeps its Projects landing page. Explicit destinations take precedence, and MFA route handoffs retain the requested destination through `redirect_to`.\n\n### Product Analytics\n\nUse `@app/lib/analytics` for new frontend product analytics. Define the event and its typed properties in the shared catalog, then capture it through the method matching its scope so required grouping is applied automatically. Do not call the legacy `Telemetry` wrapper directly from new code or define event names as local strings.\n\nRead [`../ANALYTICS.md`](../ANALYTICS.md) before adding or changing an event. It defines naming, ownership between frontend and backend producers, property cardinality and privacy rules, and lifecycle semantics. Frontend events describe UI exposure and intent; decisive product outcomes belong to backend telemetry so web activity is not double-counted against CLI, machine identity, or other clients.\n\n#### PostHog feature flags and A/B tests\n\n- Put each experiment in `src/components/analytics/experiments/<experiment>/`, with its flag key, typed variants, assignment hook, and focused Vitest coverage kept together. Shared lifecycle code belongs in `src/lib/analytics/experiments/`.\n- Resolve PostHog flags through `resolveFeatureFlagVariant`. It performs a fresh lookup after flags load and owns timeout, error fallback, late-callback suppression, unsubscribe, and unmount cancellation. Set exposure-event behavior deliberately, and persist only values that the experiment resolver recognizes as real assignments.\n- Define the eligible population before initializing PostHog. Excluded flows must not request the flag or emit the experiment conversion. An eligible user who completes after assignment fails can be attributed as `unassigned`; an ineligible user must not be counted.\n- Keep an assignment sticky for the full conversion path, including redirects or region changes. Register dedicated conversion events and typed properties in `@app/lib/analytics`; do not reuse a broader event with different semantics or default unenrolled users into the control cohort.\n- Allow forced variants only in local development or pull-request previews. Production cloud domains must use PostHog assignment, and self-hosted instances must not initialize cloud experiments.\n- Create the PostHog flag and experiment as inactive drafts. Validate variant keys, exposure, conversion events, and metrics against a preview deployment before anyone launches the experiment separately from the code rollout.\n\n### State Management\n\n- **Server state**: TanStack React Query (query key factories in each API domain)\n- **Global app state**: React Context providers in `src/context/` — User, Organization, Project, OrgPermission, ProjectPermission, ServerConfig, Subscription\n- **Local component state**: Zustand stores\n\n### Permissions\n\nCASL-based (`@casl/ability`). Contexts: `OrgPermissionContext` and `ProjectPermissionContext` in `src/context/`. Access via `useOrgPermission()` / `useProjectPermission()` hooks. HOC gates: `src/hoc/withPermission/` and `withProjectPermission/`.\n\n### Styling\n\nTailwind CSS v4 with PostCSS. Dark theme configured via CSS custom properties in `src/index.css` (`@theme` directive). Custom breakpoint `dashboard: 1100px`. Typography roles: IBM Plex Sans is the default product UI face (`font-sans`); Alliance No. 2 Regular is for headings (`font-alliance`); JetBrains Mono is the functional mono (`font-mono`) for code, values, identifiers, and technical microcopy. See the root `DESIGN.md` for typography roles. Colors use semantic roles from `src/index.css`, including surfaces, content, structure, status, scope, and product/resource accents.\n\n### Layouts\n\n9 layout components in `src/layouts/` — `AdminLayout`, `OrganizationLayout`, `SecretManagerLayout`, `PkiManagerLayout`, `KmsLayout`, `PamLayout`, etc. Layouts handle sidebar navigation and page chrome for their product area.\n\n### Org-scoped products\n\nPAM and Agent Vault live at `/organizations/$orgId/<slug>` over a single implicit project, so their URLs carry no `$projectId`. Their layout's `beforeLoad` publishes `implicitProjectId` and `implicitProductType` into route context, and `useImplicitProjectId()` / `useImplicitProduct()` read them back off the matched routes. **Anything that decides whether a `useProject()` caller renders must use `useImplicitProduct()`, never the pathname.** The router changes location before it swaps matches, so a pathname gate mounts a frame early on the way in and unmounts a frame late on the way out, leaving the component asking for a project the router no longer resolves — which throws into `ErrorPage`. Reading the matches keeps these products in step with `useParams`, which is match-derived and is what every other product's gate already uses. The gates that matter today are `OrgSidebar`'s `isInsideProject`, the `ProjectSelect` wrapper, and `OrganizationCommandMenu` in `RootCommandMenu.tsx`, which had exactly this bug when it arrived from main with a pathname check; `getOrgScopedProductFromPath` remains correct for the cosmetic uses behind no `useProject` (navbar scope, `useScopeVariant`, `TypeSelect`).\n\nA new org-scoped product therefore needs: a `<product>ProjectId` on the `Organization` type, an entry in `ORG_SCOPED_PRODUCT_TYPES` and `getOrgScopedProductFromPath` (`helpers/project.ts`), and a layout `beforeLoad` that bootstraps the id, patches it into the cached org, and returns both `implicitProjectId` and `implicitProductType` (see `pages/agent-vault/layout.tsx`). Use `isOrgScopedProduct(type)` rather than adding another pathname check; the sidebar, navbar, project switcher and nav links all key off it.\n\nCertificate Manager is org-scoped too (`/organizations/$orgId/cert-manager/...`), but legacy orgs can hold several instances, so its layout resolves the project from instance state instead of an org field, through `resolveCertManagerProjectId` (`helpers/certManagerActiveProject.ts`): the `infisical-cm-active-project-<orgId>` cookie while the user is a member of that instance, else the active instance. The product switcher and the 403 page use the same helper. The layout writes the resolved id back to the cookie, because `inject-cert-manager-project-id.ts` resolves requests that name no project from it, and reloads the page when the resolved instance changes within one page load, since several cert-manager queries are not keyed by project. Consequences: anything that sends someone into a *specific* instance (the instance picker, a project-scoped identity row, a gateway's discovery row) sets that cookie first, since the URL can't say which; old `/projects/cert-manager/$projectId/...` URLs (emails and alerts sent before the move, bookmarks; the backend now links to `/cert-manager/...`, except legacy PKI Alerts V2 and the legacy-deprecation email) go through `redirects/cert-manager-project-url-redirect.tsx`, which pins the named instance and strips the id. In multi-instance orgs each tab also tags its Certificate Manager API requests with the instance it shows (`setTabCertManagerProjectId`, read by the `apiRequest` interceptor), because the cookie is shared across tabs; single-instance orgs send no project id. Shared views that pick project vs org scope from the URL use `useRouteProjectId()`, which also returns Certificate Manager's implicit project.\n\n### Single-project products\n\nSecret Scanning has one active project per org (see the root `CLAUDE.md`). Members are sent straight into it, and UI for orgs with older projects is gated on `isMultiInstance` from `useGetSecretScanningInstanceState`.\n\n## Conventions\n\n- ESLint flat config (ESLint 9+) with airbnb-typescript + prettier. Double quotes enforced.\n- Import ordering via `simple-import-sort`: node builtins → react/external packages → `@app/` → internal → relative → styles.\n- Forms use `react-hook-form` with `@hookform/resolvers` (Zod schemas).\n- Search params validated with `zodValidator()` from `@tanstack/zod-adapter`.\n- Toasts: call `createNotification({ title?, text, type, callToAction?, copyActions? })` from `@app/components/notifications`. Backed by **sonner** (the v3 `Toaster` in `components/v3/generic/Toast`), mounted once via `NotificationContainer` in `pages/root.tsx`. `react-toastify` has been removed, so do not reintroduce it.\n- Unsaved changes: use `useDiscardChangesGuard` with `DiscardChangesAlertDialog` when dismissing an overlay editor would remove unsaved form changes. Wire dismiss/close to `requestDiscard`. Do not use `window.confirm`. Do not use `useNavigationBlocker` — that hook is secrets batch-mode only.\n","backend/CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **backend** package of the Infisical monorepo — a Fastify 4 API server with TypeScript, PostgreSQL via Knex, and BullMQ queues.\n\nThe backend code quality guide is imported below, so it is always in context for work in this package. **Check every change against it, whatever the change is, before calling the task done.** This file covers *where* code goes and *which* pattern to follow; the quality guide covers the minimum any implementation has to handle. It is a deliberately non-exhaustive floor, so the topics it happens to cover are not a filter for whether it applies.\n\n@CODE_QUALITY.md\n\n## Essential Commands\n\nAll commands run from the `backend/` directory:\n\n- `npm run dev` — start dev server with tsx watch + pino-pretty logging (`dev:docker` is the same with the debugger on 9229)\n- `npm run build` — production build via tsdown (`tsdown.config.ts`) with sourcemaps\n- `npm run lint:fix` — ESLint autofix\n- `npm run type:check` — TypeScript check (uses 8GB heap)\n- `make reviewable-api` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n\nThe backend runs on Node 26. TypeScript files run directly through tsx (dev server, scripts, the\n`*-dev` knex commands, and the e2e environment's migration loading); there is no ts-node.\n\n### Build (tsdown)\n\n`tsdown` compiles every module to its own `dist/**/*.mjs` (unbundled ESM) and rewrites `@app/*` to\nrelative paths itself. Invariants that a config change can silently break:\n\n- **The output extension stays `.mjs`.** knex records each migration's file name, extension included, in\n  `infisical_migrations`, so production rows are `*.mjs`. A different extension makes every applied\n  migration look unknown and the boot check refuses to start.\n- **`main` stays the first entry, and entry paths stay absolute.** Rolldown orders each file's imports by\n  execution order, walking entries in order; a glob that sorts another entry ahead of `main` hoists its\n  imports above the telemetry instrumentation. Object entries ignore `root`, so relative paths nest every\n  other module under `dist/src/`.\n- **Rolldown reorders imports, even within one file.** Never rely on import order for a global side effect\n  that another package needs; import it explicitly where it is needed (see `reflect-metadata` in\n  `lib/crypto/pqc/pqc-algorithm.ts`).\n- **`deps.onlyBundle: []` fails the build if a `node_modules` package would be inlined.** That means\n  `src/` imports a package that `package.json` does not declare; declare it instead of loosening the guard.\n\n### Testing\n\nRun both suites from the repo root, which is the same entry point CI uses:\n\n- `make test-api-unit` — unit tests matching `./src/**/*.test.ts`\n- `make test-api-e2e` — e2e tests matching `./e2e-test/**/*.spec.ts`\n- `make test-api-e2e SPEC=<pattern>` — narrow to one spec, e.g. `SPEC=secret-sync`\n- `make up-rotation-databases` — start the databases the secret rotation specs need, required\n  only for a full run (`docker-compose.e2e-dbs.yml`, and the Oracle image is large)\n- `make down-test-suite-containers` — stop everything when finished\n\nThe secret rotation specs reach their databases by compose service name, so\n`docker-compose.e2e-dbs.yml` pins the same compose project as the test stack and shares its\nnetwork. Without `make up-rotation-databases`, those specs fail on connection; the rest pass.\n\nBoth run inside the FIPS image (`Dockerfile.dev.fips`), which carries the native dependencies\nthe suites need (SoftHSM2, the Oracle client, the FIPS OpenSSL build) and pins the Node\nversion, so the host's does not matter. `docker-compose.test.yml` declares the image,\nenvironment, mounts and services; `src`, `e2e-test` and both vitest configs are mounted, so\nediting a test needs no rebuild but changing `package.json` or `tsconfig.json` does.\n\n**`npm run test:e2e` directly is possible but destructive if misconfigured.** The Vitest\nenvironment runs `DROP SCHEMA public CASCADE` on whatever database `.env.test` points at,\nbefore every run. Copy `.env.test.example`, which targets the throwaway stack, and start it\nwith `make up-test-suite-containers`. Never point it at the dev database.\n\nUnit tests go next to source as `*.test.ts` and test pure functions with Vitest globals (`describe`, `test`, `expect`).\n\nE2E tests live in `e2e-test/routes/`. The custom Vitest environment (`e2e-test/vitest-environment-knex.ts`) bootstraps a full server with DB, Redis, and encryption. Tests use injected globals: `testServer` (Fastify instance), `jwtAuthToken` (pre-authenticated JWT). Use `testServer.inject()` for HTTP assertions. Test helpers in `e2e-test/testUtils/` provide CRUD wrappers for secrets, folders, and secret imports. See `e2e-test/routes/v1/org.spec.ts` for a representative e2e test.\n\n#### Writing e2e tests with `testUtils`\n\n**Build test state with the helpers in `e2e-test/testUtils/`, not by hand.** Before writing a\n`testServer.inject` call or a `testDb` insert to set something up, check `testUtils/` for a helper\nthat already does it. If none exists, add one there rather than a local helper in the spec: two\nspecs had each written their own `createTargetUser` before `testUtils/users.ts` existed, and every\ncopy drifts on its own.\n\n- **Isolate each test.** `createIsolatedOrgAndProject` (`fixtures.ts`) gives a test its own org and\n  project, a token scoped to it, and a `cleanup` that deletes the org. Prefer it over the shared\n  `seedData1` org and project, whose state leaks between tests and files.\n- **Don't lean on the seed user as a participant.** When a test needs someone other than the caller\n  (a recipient, a member, a target), create them with `createUser` and `addUserMembership`\n  (`users.ts`). Those write rows directly because signup needs SRP, so the user can be named but\n  cannot log in.\n- **Go through the API for everything else**, with the domain helpers (`secrets.ts`, `folders.ts`,\n  `alerts.ts`, `reminders.ts`, `identities.ts`, ...). Write a helper with `request()` (`request.ts`)\n  so a call is sent when made and a caller can `.expect()` a non-200 or take the `.raw()` response.\n- **Assert on outcomes a user would see**: an API response, an email in `testSmtp`, a message at a\n  fake destination. Read `testDb` only when the API cannot show the thing under test (eg an alert\n  orphaned by a cascade), and say why in a comment.\n- **Wait with `pollUntil`** (`poll.ts`) for anything asynchronous, never a fixed sleep.\n- **Run background jobs on demand** through a pass-through wrapper in `e2e-test/fakes/` (eg\n  `reminder-queue.ts` and `event-outbox-queue.ts`, driven by `runDailyReminders` in `reminders.ts`).\n\n#### Faking a third-party provider\n\n**Never add test-only code to `src/`** — no test-mode enum members, no lookup map entries, no\n`isTestMode` branches. Replace the module instead, from `test.alias` in\n`vitest.e2e.config.mts`, with a double under `e2e-test/fakes/`. Production code stays unaware\na fake exists. `e2e-test/fakes/aws-parameter-store-sync-fns.ts` and its connection counterpart\nare the worked examples, and the pre-existing `./license-fns` alias is the precedent.\n\nFour things decide whether this works:\n\n- **Alias the narrowest specifier.** Entries match the import string, so aliasing one a single\n  file imports (a barrel's `./x-fns` re-export) swaps that seam and leaves the constants,\n  schemas, types, router and lookup maps real.\n- **`test.alias` must stay an array.** Vite's `mergeAlias` concatenates arrays with\n  `test.alias` first, but merges two objects, where the generic `@app` prefix matches before a\n  specific `@app/...` entry and the fake silently stops applying with no error.\n- **Re-export whatever you do not replace.** The alias swaps the whole module, so an export you\n  omit stops existing for every importer of it.\n- **Assert the fake still matches.** Nothing otherwise checks it against the module it\n  replaces. Export an assignment typed as `Pick<typeof RealModule, …>` so a signature change\n  fails type-checking rather than leaving the fake quietly wrong.\n\nA fake must reproduce the contract under test, not merely record its input. The Parameter Store\nfake reimplements the real reconciliation rules (skip empty writes, delete absent keys unless\ndeletion is disabled, respect the key schema), because those rules are the behavior the specs\nexist to pin.\n\n#### FIPS test image and the prebuilt toolchain\n\nCI (`.github/workflows/run-backend-tests.yml`) runs the e2e suite inside a FIPS image built\nfrom `Dockerfile.dev.fips`. The expensive, rarely-changing parts of that image (SoftHSM2,\nOracle Instant Client, the FIPS OpenSSL 3.1.2 build and the PQC OpenSSL 3.5.6 build) live in\n**`Dockerfile.fips-toolchain`**, which is published to\n`ghcr.io/infisical/backend-fips-toolchain` for amd64 and arm64 by\n`.github/workflows/publish-fips-toolchain.yml` on pushes to `main`.\n\n`Dockerfile.dev.fips` consumes it via the `TOOLCHAIN_IMAGE` build arg, so CI and local dev\npull the toolchain instead of spending ~15min compiling it. Consequences worth knowing:\n\n- **Editing `Dockerfile.fips-toolchain` is expensive.** CI pins the image by the content hash\n  of that file, so a PR touching it has no published tag and rebuilds from source (the old,\n  slow path). It publishes once the PR lands on `main`. Keep app-level changes in\n  `Dockerfile.dev.fips`.\n- **Don't add `cache-to: type=gha` back to the image build.** GHA cache is PR-scoped (so it\n  never hits on a PR's first run) and the repo's 10GB cache budget is already full; writing\n  image blobs there evicts the `node_modules` caches of every workflow.\n- **The published image is an internal CI build cache, not a supported artifact.** It is\n  public only so CI and local dev can pull it; it carries no support or compatibility\n  guarantee. It is *not* the FIPS product image; that one is `infisical/infisical-fips`,\n  built from `Dockerfile.fips.standalone-infisical`. OCI labels on the image say the same.\n- **Keep every third-party fetch pinned and checksummed.** SoftHSM2 is pinned to a commit\n  SHA (tags are mutable); both OpenSSL tarballs and the Oracle Instant Client zip are\n  SHA256-verified; the Infisical CLI apt package is version-pinned. Oracle Instant Client\n  is redistributed under the Oracle Free Distribution, Hosting, and Use Terms, which\n  require the license to travel with it, so `/opt/oracle/instantclient_23_26/BASIC_LICENSE`\n  must not be removed.\n\n##### Building the dev stack without GHCR\n\n`docker compose -f docker-compose.dev.yml build backend` pulls the toolchain from GHCR by\ndefault. If GHCR is down, the package is private, you're offline, or you're iterating on the\ntoolchain itself, build it locally once and point the compose build at it:\n\n```bash\n# 1. Build the toolchain from source (~15min, or ~2min if your layer cache is warm)\ndocker build -f backend/Dockerfile.fips-toolchain -t fips-toolchain:local backend\n\n# 2. Prefer .env, so it survives every compose invocation (see the warning below)\necho 'TOOLCHAIN_IMAGE=fips-toolchain:local' >> .env\n\n# ...or pass it per-invocation. Both forms work but are easy to forget:\nTOOLCHAIN_IMAGE=fips-toolchain:local docker compose -f docker-compose.dev.yml build backend\ndocker compose -f docker-compose.dev.yml build --build-arg TOOLCHAIN_IMAGE=fips-toolchain:local backend\n```\n\n**Use `.env` rather than a one-off env var.** The documented start command in\n`docs/contributing/platform/developing.mdx` is `up --build`, which rebuilds the backend and\nre-resolves the `TOOLCHAIN_IMAGE` default, so a per-invocation override silently stops\napplying and you're back to a failing GHCR pull. Compose interpolates the project `.env` for\nevery invocation, so putting it there sticks.\n\nNotes:\n\n- Step 1 is only needed once per change to `Dockerfile.fips-toolchain`. The local tag name is\n  arbitrary; it just has to match what you point `TOOLCHAIN_IMAGE` at.\n- An already-built `backend` image keeps working if GHCR is unreachable, as long as you don't\n  pass `--build`. This only bites on a rebuild.\n- This is not an offline story. The toolchain build itself still fetches Debian packages, both\n  OpenSSL tarballs, SoftHSM sources, and the Oracle client. With no network at all, neither\n  path works and you need a previously built image.\n\n### Database\n\n- `npm run migration:new` — create new migration (interactive prompt)\n- `npm run migration:latest-dev` — run pending migrations (dev, uses `src/db/knexfile.ts`)\n- `npm run migration:rollback-dev` — rollback last migration batch (dev)\n- `npm run generate:schema` — regenerate Zod types from DB into `src/db/schemas/` (always run after migration changes)\n- `npm run seed-dev` — run database seeds\n\n- **Every foreign-key column must be covered by an index** (its own, or as the leftmost column of a composite index). Postgres does **not** auto-index FK columns. This holds regardless of cascade behavior: an unindexed FK forces a seq-scan of the child table on every parent `DELETE`/`UPDATE` (per-row RI trigger), and the FK column is almost always also a join/filter key. Watch helper-generated FKs — `createJunctionTable` (`src/db/utils.ts`) creates CASCADE FK columns with **no index** on either side.\n\n### Scaffolding\n\n- `npm run generate:component` — interactive generator that can create a service module (DAL + service + types), a standalone DAL, or a router\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## ESLint & Import Ordering\n\nConfig in `.eslintrc.js`. Uses `simple-import-sort` with this group order:\n1. Side-effect imports\n2. `node:` builtins\n3. Third-party packages\n4. `@app/` imports\n5. `@lib/` imports\n6. `@server/` imports\n7. Relative imports\n\n## Architecture\n\n### Service Factory + Manual DI\n\nNo IoC container. Every service is a factory function that receives explicit dependencies as a typed object and returns an object of methods. Dependencies are narrowed with TypeScript `Pick` to define minimal interface contracts.\n\nThe entire dependency graph is manually wired in `src/server/routes/index.ts`:\n- **~line 480-646**: DAL instantiation — each DAL factory receives the `db` client\n- **~line 649-2800**: Service instantiation — each factory receives its DALs + other services\n- **~line 2831-2972**: `server.decorate(\"services\", {...})` exposes ~100+ services as `server.services.*`\n- **~line 3082-3098**: Route registration — EE routes registered before community routes per API version\n\nSee `src/services/secret/secret-service.ts:104-162` for a representative factory with ~20 dependencies. See `src/services/user/user-service.ts` for a simpler example (~8 dependencies).\n\n### DAL Layer (Data Access)\n\nEach service has a `*-dal.ts` that wraps `ormify()` (defined in `src/lib/knex/index.ts:155-326`). `ormify()` provides typed CRUD methods: `findById`, `find`, `findOne`, `create`, `insertMany`, `batchInsert`, `upsert`, `updateById`, `update`, `deleteById`, `delete`, `countDocuments`, `transaction`.\n\nDALs extend these with custom queries — typically complex joins using Knex query builder and `sqlNestRelationships()` for nested entity mapping. All methods accept an optional `tx` parameter for transaction threading.\n\n**Read replica pattern**: Read methods use `db.replicaNode()` (e.g., `(tx || db.replicaNode())(tableName)`), while writes always hit the primary (`(tx || db)(tableName)`). See any `ormify()` method in `src/lib/knex/index.ts` for the pattern.\n\n`updateById` and `update` support atomic `$incr` and `$decr` operators alongside regular field updates.\n\n**A dropped column does not fail type checking if the insert is built in a `.map()`.** TypeScript's\nexcess-property check only fires on a fresh object literal at the assignment site, so a stale field\nsurvives when the literal is returned from a callback:\n\n```ts\ninsertMany(names.map((name) => ({ name, role: \"member\", orgId })));  // compiles, then 500s at runtime\ninsertMany([{ name, role: \"member\", orgId }]);                       // TS2353\n```\n\nSee `src/services/secret/secret-dal.ts` for a DAL that overrides the base `update` to auto-increment version and adds complex join queries.\n\n### Service Module Structure\n\nServices live in `src/services/` (100+ modules). Each typically contains:\n- `*-dal.ts` — data access via `ormify()` + custom queries\n- `*-service.ts` — business logic factory\n- `*-types.ts` — DTOs and type definitions\n- `*-queue.ts` — BullMQ async job handlers and/or cron-manager registrations (when needed)\n- `*-fns.ts` — pure utility functions (when needed)\n\n### Route Handler Pattern\n\nRoutes use Fastify's Zod type provider — schemas auto-generate OpenAPI docs. Each route specifies: `method`, `url`, `config.rateLimit` (using `readLimit` or `writeLimit` presets), `schema` (Zod schemas with `operationId` for OpenAPI), `onRequest: verifyAuth([AuthMode.*])`, and a `handler` that accesses business logic via `server.services.*`.\n\nSee `src/server/routes/v4/secret-router.ts` for a representative router file.\n\n### Auth System\n\nAuth extraction happens in `src/server/plugins/auth/`:\n- `inject-identity.ts` — detects auth mode from request headers and attaches identity to request\n- `verify-auth.ts` — middleware that checks if the request's auth mode is in the route's allowed strategies\n- `inject-permission.ts` — attaches permission context\n- `inject-assume-privilege.ts` — privilege escalation support\n- `superAdmin.ts` — super admin flag injection\n\n**Auth modes** (defined in `AuthMode` enum):\n- **JWT** — user browser sessions (decoded from `Authorization: Bearer` header)\n- **IDENTITY_ACCESS_TOKEN** — machine-to-machine identity tokens\n- **SCIM_TOKEN** — SCIM provisioning tokens\n- **OAUTH** — delegated user tokens from the `oauth-client` module. Same JWT shape and session binding as\n  `JWT`, told apart by an `oauthClientId` claim. See [Delegated OAuth tokens](#delegated-oauth-tokens).\n\n**Deprecated auth modes (do not use in new code):**\n- **API_KEY** — user API keys (from `x-api-key` header). Deprecated — use identity access tokens instead.\n- **SERVICE_TOKEN** — service tokens (Bearer tokens starting with `st.` prefix). Deprecated — use identity access tokens instead.\n\nIf you encounter `API_KEY` or `SERVICE_TOKEN` in existing code, do not replicate them in new routes or services. All new machine authentication should use `IDENTITY_ACCESS_TOKEN`.\n\nToken detection logic in `inject-identity.ts` checks `x-api-key` header first, then parses `Authorization: Bearer` and inspects JWT `authTokenType` field to determine mode.\n\n#### Delegated OAuth tokens\n\nTwo grants issue them: the authorization code flow (consented `scopes`) and the RFC 8693 token exchange\n(`delegation: \"full\"`, no scopes).\n\n**Opting a route in.** `verify-auth.ts` rejects `AuthMode.OAUTH` on any route that doesn't list it; most\nproduct `AuthMode.JWT` routes do. Two things have to hold before adding it. The route must not be an\nadministration route (below). And the handler must actually **check** a CASL ability, because that check\nis where the granted scopes get intersected in: building an ability and throwing it away\n(`getOrgPermission` used as a membership gate), or reading `hasRole(...)` off its return value, narrows\nnothing; `hasRole` is the easy one to miss because it reads like authorization. Rough proxy: nothing\npassing `requireOrg: false` accepts `AuthMode.OAUTH`.\n\n**Administration is read-only for a delegated token.** A delegated token can look at anything the\nauthorizing user can, but it changes nothing under **Administration**: no create, update, or delete, at\neither level. The frontend nav is the boundary, and it is worth opening (`OrgNav.tsx`,\n`OrgSubmenuView.tsx`, `SecretManagerNav.tsx`, `submenus.tsx`) when a new route's side is unclear. The\nsurfaces it covers:\n\n- **org access control** — members, groups, identities (including memberships, templates, and all\n  thirteen `identity-*-auth-router.ts`), org roles, invites, external group-to-role mapping\n- **usage & billing** — `ee/v1/license-router.ts`, `ee/v1/license-v2-router.ts`\n- **audit logs** — the org and project log reads plus audit log streams\n- **org settings** — org update and incident contacts, SSO and provisioning (SAML, OIDC, LDAP, SCIM,\n  GitHub org sync, email domains), networking (gateways, gateway pools, relays), encryption (external\n  KMS, KMIP, KMIP servers, HSM connectors), project templates, sub-orgs, workflow integrations\n  (Slack, Microsoft Teams), and the org-wide EnvKey/Vault migrations on the settings tab\n- **project access control** — memberships for users, groups and identities, project roles, additional\n  privileges, folder grants, project keys, service tokens, and the per-resource equivalents under PAM,\n  cert manager, code signing and PKI applications\n- **project settings** — project update and delete, environments, tags, approval policies, webhooks,\n  trusted IPs, secret validation rules, project KMS, blind index, audit log retention, E2EE upgrade,\n  and the secret scanning `configs` routes\n\nIn practice that means `GET` keeps `AuthMode.OAUTH` and `POST` / `PATCH` / `PUT` / `DELETE` lose it. The\nmethod is the test because `CODE_QUALITY.md` already requires `GET` to be safe, so it needs no per-route\njudgment. Two adjustments to that rule, both small enough to enumerate:\n\n- **A `POST` that only reads keeps it.** Five endpoints are `POST` solely because a lookup needs a request\n  body: `POST /v1/identities/search`, `POST /v2/identities/search`, `POST /v2/identities/search/count`,\n  and the two `memberships/details` routes. All five check a CASL read ability.\n- **A read that hands back credential material loses it**, because the point of closing administration is\n  that a third-party application should not end up holding the org's secrets. Five routes:\n  `GET /v1/sso/oidc/config` (`clientSecret`), `GET /v1/ldap/config` (`bindPass`),\n  `GET /v1/workspace/:projectId/kms/backup` (the project's KMS backup blob), and Slack\n  `GET /install` / `GET /reinstall`, which gate on `OrgPermissionActions.Create` and exist only to start\n  an install. Everything else reads through a sanitized schema — `sanitizedClientSecretSchema` returns a\n  prefix, `sanitizedExternalSchemaForGetById` drops provider credentials, `ScimTokensSchema` carries no\n  token. Check the response schema, not the route name, before opening a new administration read.\n\nThree things are first-party outright, because they sit outside an org and the nav boundary doesn't\nreach them:\n- account self-management (user, password, MFA, sessions, login, signup, notifications, announcements)\n- super-admin routes (`v1/admin-router.ts`), where `verifySuperAdmin` reads\n  `user.superAdmin` rather than an ability\n- `POST /v2/organizations`, and the org reads that return more than the token's org: `GET /v1/organization`,\n  `GET /v1/organization/accessible-with-sub-orgs`, `GET /v1/organization/:organizationId`. All pass\n  `requireOrg: false`, so nothing narrows them\n\nEverything else that used to be listed here individually — OAuth client CRUD, assume-privilege, deleting\nan org or sub-org, minting SCIM tokens, KMIP client certificates or gateway / relay / KMIP-server\nenrollment tokens, registering identity auth methods — is an administration write, and closed by the rule\nabove rather than by its own exception. Two of those reasons still generalize, so keep them in mind when a\n**product** route does the same thing:\n\n- **A route that leaves behind something able to authenticate later can't accept a delegated token.**\n  Revocation reaches only the sessions tagged with `getOauthClientSessionUserAgent`, so a credential or an\n  auth method bound to a key the caller holds survives deleting the application and rotating its secret,\n  and authenticates as a principal no OAuth scope intersects. Whether the route hands secret material back\n  is beside the point, so start any new route that writes a trust anchor (bound issuer, signing key, CA,\n  cloud principal) first-party.\n- **Nor can a route that reissues the caller's session.** `DELETE /v2/organizations/:organizationId`\n  returns a fresh pair from `loginService.generateUserTokens`, which takes no delegation parameter, so a\n  delegated caller would exit with a first-party session.\n\n**Delegation markers.** A token carries `scopes` or `delegation: \"full\"`, never both\n(`oauth-client-types.ts`). `permission-service` intersects the user's CASL rules with `scopes`\n(`applyOauthScopeToOrgRules` / `applyOauthScopeToProjectRules`). `inject-identity.ts` sets\n`RequestContextKey.OauthScopes` for every scope-narrowed token (even `[]`, which denies everything) and\nleaves it unset only for full delegation. Matching full delegation **positively** is what makes a dropped\nclaim fail closed instead of promoting the token.\n\n**The token endpoint's error contract is RFC 6749 §5.2, not the house envelope**, because generic OAuth\nlibraries branch on the `error` code. `OauthTokenError` (`oauth-token-error.ts`) carries the code and\nderives the status from it (400; 401 for `invalid_client`, 500 for `server_error`), and the shared error\nhandler renders `{ error, error_description }`. When extending it:\n- The handler's `try`/`catch` is the endpoint's only exit, and every `return` inside it is **awaited**: a\n  bare `return promise` would resolve outside the `catch` and escape in the house envelope.\n- `toOauthTokenError` defaults by **error class**, never message text: `UnauthorizedError` and\n  `ForbiddenRequestError` take the rejected-grant code for that grant (`invalid_grant` on the redirect and\n  refresh grants, `invalid_request` on token exchange, per RFC 8693 §2.2.2), `BadRequestError` takes\n  `invalid_request`, and anything else becomes `server_error`, whose response carries no detail and whose\n  original is logged. Both refusal classes are listed because the exchange rejects an unusable subject in\n  two vocabularies: `exchangeSubjectToken`'s own membership checks raise `UnauthorizedError`, while\n  `getOrgPermission` raises `ForbiddenRequestError` for the same kind of refusal. A new way to refuse a\n  subject belongs in one of those two classes, or it lands in `server_error` as an unexplained 500.\n- Throw `OauthTokenError` directly where that default is wrong: client authentication\n  (`invalid_client`), a grant the client doesn't hold (`unauthorized_client`), a redirect-URI or PKCE\n  mismatch (`invalid_grant`), the unsupported `scope` (`invalid_scope`) and `audience`/`resource`\n  (`invalid_target`) parameters, and org misconfiguration or IdP outages (`server_error`).\n- The route sets `attachValidation` so a schema failure reaches the handler; an unrecognized `grant_type`\n  earns `unsupported_grant_type`.\n- `toErrorDescription` sanitizes and bounds `error_description` at the render boundary, so no throw site\n  has to remember RFC 6749's printable-ASCII restriction.\n- Messages shared with a management route (the OIDC-config checks) take an error factory rather than\n  throwing, since the two callers owe the same explanation in different envelopes.\n\n**Token exchange trusts the org's OIDC SSO config, not per-client configuration**\n(`oauth-token-exchange-fns.ts`), so the issuers that can vouch for a user are the ones that can already\nlog them in. The one per-application field is `tokenExchangeAudience`, and it does the security work:\nwithout it, any token that issuer signed for anything in the estate is exchangeable. Enabling the grant,\nchanging the audience, and rotating the secret each need `OrgPermissionSsoActions.Edit` on top of the usual\n`OauthClients` check (`checkSsoConfigPermission`), rotation included, since its response is a working\ncredential for acting as any of the org's users.\n\n`getOrgPermission` is **not** a membership check: the org-scope query in `permission-dal.ts` never reads\n`Membership.isActive` or `Membership.status`. Every other user token came from a browser login, where\n`selectOrganization` rejects an inactive membership; the exchange has no login in front of it, so\n`exchangeSubjectToken` checks membership state itself. Any future non-interactive way of minting a user\ntoken needs the same check.\n\n**Withdrawing an exchange application's authority revokes the tokens it already issued.** Deleting the\nclient, rotating its secret, and any update that narrows the exchange's trust (dropping the grant,\nchanging `tokenExchangeAudience`, switching `tokenExchangeIdpSatisfiesMfa` off) all call\n`revokeSessionsByUserAgent`. `hasWithdrawnTokenExchangeTrust` and `hasClientAuthorityChanged` guard the\nsame fields, so a new field belongs in both. The test is whether it carries federation trust, not merely\nwhether it can be narrowed. `accessTokenTTL` is in neither: it only decides how long a new token lasts, so\nrevoking on a routine edit would surprise, and guarding it would fail in-flight exchanges. Widening\n(turning the MFA declaration on) revokes nothing. Rotation revokes for exchange clients only, where the\nsecret alone mints tokens; in the redirect flow it has to be paired with a code or refresh token, so\nblanket revocation would just sign everyone out. The session tag is per client, which is unambiguous\nbecause `assertValidOauthClientGrantConfig` rejects the exchange grant alongside `authorization_code`, so\na service needing both registers twice.\n\nThat sweep only reaps sessions that already exist, so **the exchange rechecks the client after creating\nits own session** (`findByIdForUpdate` + `hasClientAuthorityChanged`, re-running the sweep). The other\ngrants read an existing session rather than creating one, so they don't need it. The recheck has to be a\n**locking** read on the primary, and both halves of that are load-bearing: each withdrawal path writes the\nclient and sweeps sessions inside one transaction, so a plain read still sees the pre-withdrawal row until\nthat transaction commits, and would clear a token whose session the sweep has already scanned past. A\nreplica read has the same hole for the length of the replication lag. Keeping the write and the sweep\natomic is what makes the lock the fix here; splitting them so the write commits first would reopen a\nwindow where the sweep can fail on its own and leave the tokens live.\n\n**Everything the exchange fetches from the IdP is cached for 10 minutes; nothing read from our own\ndatabase is**, so an admin editing the SSO config takes effect on the next request while the provider\nnever becomes a synchronous dependency of the middleware's own request path. The cache invariants\n(rejections evicted, in-flight promise shared, expiry rebuilding the SSRF-pinned agent) are documented in\n`oauth-token-exchange-fns.ts`.\n\n### Resolving Users From Provisioning Identifiers\n\nEvery invite / removal path resolves a person by `users.username` (which always equals their email),\nnever by `users.email` and never by a join. An external provisioning system, though, often knows\npeople only by the identifier its IdP asserts, a UPN like `m249913@one.example.com` rather than the\nmailbox `robert@example.com`. SSO login already records that identifier as\n`user_aliases.externalId`, so the two are reconcilable without making `username` diverge from\n`email`.\n\n`resolveUsersBySsoExternalId` in `src/services/user-alias/user-alias-fns.ts` is the one place that\ndoes it, backed by `userAliasDAL.findBySsoExternalIds`. Callers run it **only after an exact\n`username` lookup has missed**, so an alias can never shadow a real account. It is wired into\n`$getUsers` (`membership-user-service.ts`, the single funnel for org, project, and cert-manager\ninvites), `project-membership-service.ts` (removal and membership read-back), and PAM's\n`addProductUserMembers`.\n\nFour invariants, each load-bearing:\n\n- **Scope by `orgId` *and* `aliasType`.** `user_aliases.orgId` is NULL for the global\n  google/github/gitlab aliases, so `whereIn(\"orgId\", ...)` excludes them outright and the\n  `ORG_SCOPED_USER_ALIAS_TYPES` filter is the second lock. Without both, an org admin could name a\n  user in another tenant.\n- **Match `externalId` exactly, never folded.** It is a case-sensitive identifier (OIDC Core defines\n  `sub` that way, as does SAML for nameID) and is stored verbatim, so folding case could collapse two\n  distinct IdP subjects onto one identifier. The known consequence: because both invite routes\n  lowercase their input (`sanitizeEmail`, and the `.refine` on `usernames`), an IdP that asserts\n  mixed-case identifiers cannot be provisioned against at all. Supporting those means relaxing that\n  input validation so the exact identifier survives to the query, not loosening the comparison.\n  `adoptProvisionedShadowUser` does not fold either. It derives its lookup key with `sanitizeEmail`\n  because it searches the `users.username` namespace, where lowercase is canonical, but it then\n  **refuses any identifier that is not already canonical** rather than adopting on the folded match.\n  It once did adopt, and that was a real hole: a subject differing only by case is a different\n  subject, so it could take over the placeholder provisioned for another one and inherit its grants.\n  Folding bought nothing anyway, since the alias written afterwards is verbatim and this exact-match\n  lookup could never find it again, stranding the grant where provisioning cannot manage it.\n- **Ambiguity is an error.** Nothing constrains `(externalId, aliasType)` to be unique for the\n  org-scoped types (only the social ones have a partial unique index), so an identifier can reach two\n  users. Picking one would be a guess about which human it names, and the cost of guessing wrong is\n  granting access to the wrong person. Several aliases on the *same* user are not a conflict.\n- **Dedupe resolved users by `id`.** An identifier now reaches a user by either their username or\n  their alias, so one request can name the same person twice. Undeduped, that violates\n  `membership_unique_user_org` and surfaces as a 500.\n\nNothing constrains a user to one alias per `(orgId, aliasType)`, and SSO login mints a new one\nwhenever the asserted subject differs from what SCIM last wrote, so **a SCIM read must match a\n`userName` against every one of a user's aliases, not the newest**. Matching only the newest made a\nprovisioned user vanish from `GET /Users?filter=userName eq \"...\"` the first time they logged in\nunder a different subject, and the IdP answered that empty lookup by provisioning them again. It was\nintermittent because `replaceScimUser` rewrites `externalId` on *all* of a user's aliases, so the\nnext PUT healed it until the next login. `$buildScimMembershipQuery` (`org-dal.ts`) therefore\nanswers every `userName` comparison with an `EXISTS` over the user's aliases, so the predicate is per\nuser rather than per alias row. That matters for negation: evaluated per row, `userName ne \"x\"` or\n`not (userName eq \"x\")` would keep a user through their other alias, and a member with no alias at\nall would fall out through a NULL comparison. The parser (`lib/knex/scim.ts`) lets an attribute\nresolve to a handler instead of a column for exactly this. Display is separate: the query still\njoins every alias and collapses the fan-out with `DISTINCT ON`, ranked so the alias row that\nsatisfies the filter wins and the newest is the fallback, which is how a lookup by an older alias\nechoes that alias back. The list query also carries a total order, because an IdP walking\n`startIndex`/`count` over an unordered result loses users the same way.\n\nA related case sits on the login side: provisioning can name someone before they have ever logged\nin, leaving a placeholder account keyed on the identifier instead of the mailbox.\n`adoptProvisionedShadowUser` (same file, wired into `oidcLogin`'s no-alias branch) adopts that row\nand rewrites it to the asserted mailbox rather than creating a second account. It refuses anything a\nhuman has claimed (accepted, email-verified, holding a password), anything already bound to an IdP\n(any alias, any org), ghosts, anything whose identifier is not already canonical (see above), and\nanything without a membership in the org doing the login.\n\nOne case is a refusal to *log in* rather than a refusal to adopt: a placeholder whose membership in\nthe login org is **inactive**. Declining is not neutral there, because the caller reads a `null` as\n\"no placeholder\" and creates a second account with a fresh active membership, handing a deactivated\nperson their org back (`selectOrganization` then accepts it). So the inactive check is resolved\nbefore every remaining decline — the alias check and the cross-tenant check both sit after it — and\nit throws the same `ForbiddenRequestError` an already-aliased deactivated member gets.\n\nIt also refuses a placeholder that holds an org membership **outside** the login org's own sub-org\nfamily, and that one is the security-critical check rather than a tidiness one. The username lookup\nthat finds the placeholder is global, so a second tenant that invited the same identifier shares the\nrow; adopting it would hand the login org's IdP subject that tenant's memberships, and\n`selectOrganization` accepts a membership in any status (promoting `Invited` to `Accepted` on\narrival), so nothing downstream stops the inherited access from being used. A project membership\nalways implies an org membership in the same org, so the org-scope check covers project access too.\n\nAdoption also recovers from a unique violation on `users.username`, because the caller's preceding\nread is not a lock. That recovery has to run inside a savepoint (`tx.transaction()`, which knex\ncompiles to `SAVEPOINT`/`ROLLBACK TO SAVEPOINT` on the same connection): Postgres aborts the whole\ntransaction on a constraint violation, so an unscoped retry would fail with `25P02`, taking the\ncaller's remaining alias and membership writes with it. SAML and LDAP have structurally identical\nbranches and are deliberately not wired up.\n\nBecause adoption rewrites an existing account's `username` and `email`, it emits an\n`OIDC_PROVISIONED_PLACEHOLDER_ADOPTED` audit event carrying the before and after, not just an\napplication log: if one of the refusal checks above ever regresses, the audit trail is what makes it\nfindable. That event must never fire on the unique-violation recovery path, where the returned user\nis whoever won the race rather than a rewritten placeholder. `adoptProvisionedShadowUser` draws that\nline by returning `adoptedFromUsername: null` for the yield, and the caller keys the audit log on\nit.\n\nThat trail is best-effort, not guaranteed. `audit-log-queue.ts` drops every entry at push time when\n`plan.auditLogsRetentionDays` is 0, which is the default for a self-hosted instance with no\naudit-log entitlement, so on those deployments only the `logger.info` line survives an adoption.\n(`null` is the License Server's \"No limit\" and keeps everything; see `audit-log-retention-fns.ts`.)\nDo not special-case this event past the retention gate; treat the application log as the floor and\nthe audit event as the addition for licensed instances.\n\n### Profile Sync From The IdP (SSO-enforced orgs)\n\nAn IdP keyed on a stable identifier lets someone change mailbox and display name without changing\nwho they are, so our copy of both goes stale: notification mail goes to an address that no longer\nexists, and every audit entry written from then on records it. `syncSsoUserProfile`\n(`src/services/user-alias/user-alias-fns.ts`) carries the asserted email and name onto the account,\nand is called from all three login services right after `ensureSsoAccountVerified`, outside the\ncaller's transaction, with its result flowing into the session.\n\nThe gate is `organization.authEnforced` **and** a verified alias, and both halves matter. Enforcement\nis the org saying the IdP is authoritative for identity, which is the same claim that already skips\nemail verification at signup. The verified alias is the proof that the IdP controls *this* account:\nan unverified alias asserting an unrecognized email is exactly what `isStaleSsoAlias` exists to\ncatch, and reading that as a rename would let a stale alias rewrite somebody else's account. One\nconsequence to know: a legacy unverified alias whose email changed before its next login is stale,\nso it gets the email-verification fallback (a code sent to the dead mailbox) rather than a sync.\n\nThree invariants:\n\n- **It never fails a login.** A rename that could not be applied leaves stale data, which is what we\n  already had; a throw locks the person out of an org that has no other way in. Every failure path\n  returns the unchanged user.\n- **It only renames an address the org owns, onto another address the org owns.** Both halves are\n  checked in the helper against the org's verified domains. The user row is global rather than\n  org-scoped, so without the first half an org could rename an account that merely carries one of\n  its aliases and rewrite the identity every other org of that user sees. Every login path\n  establishes both already (`verifyEmailDomainOwnership` is a positive check: the domain must be\n  verified *for this org*, and the alias branch runs it against the existing account's username),\n  so the helper's copy is there to keep the rename from outliving those checks. It is unreachable\n  today, and audits the skip anyway (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"domain-not-owned\"`) so a refactor that removes a caller's check leaves a trail rather\n  than a silently stale account.\n- **It never renames onto an occupied address.** `users.username` is globally unique and the row is\n  global rather than org-scoped, so the asserted address may already be a personal signup or an\n  unmerged duplicate. The conflict is recorded (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"address-taken\"`) and the email is left alone; the name still syncs. The preceding read\n  is not a lock, so a unique violation lands on the same path.\n\nSCIM is the other half of the same story and moves with it. `updateScimUser` / `replaceScimUser`\nrejected every email change outright, which left the data stale *and* put the provisioning job in a\npermanent error state (Entra quarantines after repeated failures). Both now accept the change under\nthe same `authEnforced` gate via `$resolveScimEmailChange`, and answer an occupied address with\n`409 uniqueness` rather than a constraint-shaped 500. Self-service email change\n(`user-service.ts`) is refused when the next login would overwrite it anyway, which\n`$getManagedEmailReason` narrows to the case that actually would: the account's own address sits on a\ndomain one of its SSO-enforced orgs has verified. Membership in an enforced org is not the test, since\nthe user row is global and an address outside that org's domains is one `syncSsoUserProfile` will not\ntouch. SCIM stays a blanket refusal, because the directory provisions the address either way.\n\n### Permission System (CASL)\n\nUses CASL (`@casl/ability`) with MongoDB-style rules. Permission logic lives in `src/ee/services/permission/`:\n- `permission-service.ts` — factory that builds CASL abilities from user/identity roles\n- `project-permission.ts` — defines project-level permission actions and subjects\n- `org-permission.ts` — defines org-level permission actions and subjects\n\n**Project permission actions** include standard CRUD plus specialized ones like `DescribeSecret` (see metadata without value), `ReadValue`, `GrantPrivileges`, `AssumePrivileges`, `Lease` (for dynamic secrets). See `ProjectPermissionActions`, `ProjectPermissionSecretActions`, `ProjectPermissionDynamicSecretActions`, and `ProjectPermissionIdentityActions` enums in `project-permission.ts`.\n\nBuilt-in roles: `Admin`, `Member`, `Viewer`, `NoAccess`. For PAM and Agent Vault `getPredefinedRoles` (`project-role-fns.ts`) returns only `Admin` and `Member`, because their permission dispatch resolves every other slug to the member set; the role factory delegates to that one function, so every role picker follows. Custom roles use unpacked CASL rules stored in the database. Rules can include conditions with operators `$IN`, `$EQ`, `$NEQ`, `$GLOB` (for pattern matching like `prod-*`). See `PermissionConditionSchema` in `permission-types.ts`.\n\n**Privilege boundaries mean different things on the two privilege systems.** `organizations.shouldUseNewPrivilegeSystem` (default `true`, backfilled `false` for orgs predating March 2025) picks which. `validatePrivilegeChangeOperation` / `assertRoleSetBoundary` (`permission-fns.ts`) are the shim: on the new system they collapse to `actorPermission.can(action, subject)`, on the legacy one they run `validatePermissionBoundary`, requiring the actor to out-rank every role the target holds. So a boundary at a call site that already gated on the same action is a no-op for new-system orgs and the only protection for legacy ones. Two consequences: \"the route checks the action\" is not a substitute for a boundary, and adding one is not a behavior change for most tenants. Prefer `getOrgPermissionByRoles` / `getProjectPermissionByRoles` + `assertRoleSetBoundary` over fetching the target's merged ability. It bounds each role separately, and it avoids `getProjectPermission`, which stamps whichever actor it was called for onto the request context the audit log reads.\n\n**Identity auth-method access goes through `assertIdentityAuthAccessAllowed`** (`src/services/identity/identity-auth-permission-fns.ts`), because repointing an identity's auth trust lets the caller authenticate as that identity. It's both the gate and the boundary: it checks the identity action itself on both privilege systems, and on legacy also makes the actor out-rank every grant the target holds. The boundary alone isn't enough, since it passes for any target you cover, and for an org identity it only sees org-scope grants, so a no-access org identity that's admin in projects would be open to anyone. Callers don't repeat the action check. The 13 auth services and the template update call it once per attach/update/revoke, outside the `identity.projectId` branch so both scopes are covered, and a new auth method should too. Only the fine-grained action gates these paths, at both scopes; the old coarse org checks (`edit`, `create`, `read`, `delete`) are gone. Credential issuance (`createTokenAuthToken`, `createUniversalAuthClientSecret`) goes through it under `create-token`, since minting a credential is the same escalation as repointing the trust, and so do the per-credential paths: `updateTokenAuthToken` under `create-token`, `getUniversalAuthClientSecrets` / `getUniversalAuthClientSecretById` under `get-token`, and `revokeUniversalAuthClientSecret` under `delete-token`. **`PROJECT_ACTION_BY_ORG_ACTION` must map each org action to its project equivalent**, since project identities get checked against the mapped one. `revokeTokenAuthToken` and `clearUniversalAuthLockouts` are still unbounded in both scopes (and the former gates on `edit` where its UA counterpart gates on `delete-token`); that predates the helper and is a deliberate gap, not an oversight to copy. The target's grants come from `permissionService.getActorGrantAbilities`, not from a membership lookup, and it returns one ability per grant rather than per role. Two things a role-slug resolver misses: a group-derived membership carries a NULL `actorIdentityId`, so `membershipIdentityDAL.getIdentityById` never sees it, and an additional privilege carries a raw permission blob with no slug at all, so no `*PermissionByRoles` path can express it. Either omission clears an actor that out-ranks the target's direct roles but not its effective access. `getActorGrantAbilities` reads the same `permissionDAL.getPermission` query the ability itself is built from, so the bounded set cannot drift from the effective one.\n\n**Auth template propagation is serialized with row locks on `identity_auth_templates`.** A template edit rewrites every linked identity's auth, and a link copies the template's values onto the identity row, so the two must not interleave: a link landing between the edit's permission loop and its writes would be rewritten by an actor never checked against it, and an edit landing between a link's validation and its insert would leave the identity holding values the template no longer has. `updateTemplate` and `deleteTemplate` take the row `FOR UPDATE` as the first statement of their transaction, and the edit re-reads usages through `tx` under that lock and returns a 409 if an identity appeared since the checked set was built; its propagation writes are also scoped to the checked ids so the privilege invariant does not rest on the lock. Every path that copies template values onto an identity (the three attaches, and the three identity updates when they load a template) calls `assertTemplateUnchangedForLink` (`ee/services/identity-auth-template/identity-auth-template-fns.ts`) as the first statement of its transaction: a `FOR SHARE` read plus an `updatedAt` comparison against the row it validated outside the transaction, 409 on mismatch. Lock order is template row first, identity row second; unlink and non-linking identity updates take no template lock and never wait on it, so no cycle is possible. Nothing slow may sit under either lock, which is why LDAP attach runs `testLDAPConfig` before its transaction opens. A new path that copies template values onto an identity must call the helper inside its transaction.\n\n**Deny an RBAC check by throwing through CASL, never with `ForbiddenRequestError`.** `audit-log-permission-denied.ts` records a permission-denied audit event for every CASL `ForbiddenError` and `PermissionBoundaryError` a request throws, and skips plain `ForbiddenRequestError` because most of those are auth-mode, plan and ownership refusals. A service that calls `permission.can()` and throws `ForbiddenRequestError` itself therefore produces no denial event, and nothing fails to tell you. When the check has a fallback (a second action, an approver, an application grant), test the fallbacks first and make `ForbiddenError.from(permission).throwUnlessCan(...)` the last check, so the denial is recorded with a real ","DESIGN.md":"# Infisical Design System (v3)\n\nThis document captures the v3 visual language and product voice used across\nInfisical. It is the single reference for engineers, designers, and AI coding\nagents producing new UI or user-visible copy.\n\n**Source of truth for tokens and themes:** [`frontend/src/index.css`](frontend/src/index.css) (`@theme` blocks and theme overrides).\n**Canonical semantic reference:** [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx).\n**Canonical page references:** [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) and [`AccessControlPage`](frontend/src/pages/project/AccessControlPage).\n**Component usage reference:** Start with the sibling story in [`v3/generic`](frontend/src/components/v3/generic) or [`v3/platform`](frontend/src/components/v3/platform) when one exists, then check source and consumers. Some exports, including `CopyButton`, `DataGrid`, and `Sidebar`, have no sibling story; the [generic](frontend/src/components/v3/generic/index.ts) and [platform](frontend/src/components/v3/platform/index.ts) barrels are the complete export catalogs.\n\n---\n\n## 1. Visual Theme & Atmosphere\n\nInfisical is a security tool for operators. The interface reads like\ninfrastructure: dense, calm, and legible. Decorative treatments are reserved\nfor brand-forward surfaces such as authentication and onboarding; the core\nproduct remains utilitarian. Dark is the default medium; the page canvas is\n`--color-page`. Light mode is implemented through semantic token overrides and\nthe theme provider (with intentionally forced-dark authentication paths).\nCheck shared surfaces in both themes rather than hard-coding dark contrast.\n\nColor carries **meaning before brand**. A danger badge is red because the\naction is destructive, not because red is the accent. A project-colored button\nsignals project scope, not visual variety. Designers pick intent; hex values\nfollow.\n\nDepth is drawn primarily with borders and surface tones; the shared v3 `Card`\nadds a subtle `shadow-xs`. Motion is restrained and clarifies state changes;\ncontrol and collapse transitions often use 200ms, while overlays own their\nentry/exit timing. Respect reduced-motion behavior rather than assuming all\nanimations share one implementation. Secret values are masked by default;\nrevealing one is an intentional act.\n\n**Key characteristics:**\n\n- Dark default with light semantic overrides; `--color-page` page canvas\n- Semantic-first color (danger / success / warning / info / neutral)\n- Scope- and product-aware (org / sub-org / project / admin / PAM / Agent Vault)\n- Border-first depth with the shared `Card`'s `shadow-xs`; overlapping labels\n  retain the shared Badge styling\n- IBM Plex Sans for product UI, Alliance No. 2 for headings, and JetBrains\n  Mono for monospaced content\n- Secrets masked by default; reveal is an act\n\n## 2. Color Palette & Roles\n\nAll colors are defined as CSS custom properties in\n[`frontend/src/index.css`](frontend/src/index.css) and consumed via Tailwind v4\nutilities (`bg-org`, `text-danger`, etc.). Never introduce a hex that is not\nin this file.\n\n### Scope colors (hierarchy)\n\nUsed to signal the scope a surface, badge, or action belongs to.\n\n| Scope            | Token             |\n| ---------------- | ----------------- |\n| Organization     | `--color-org`     |\n| Sub-Organization | `--color-sub-org` |\n| Project          | `--color-project` |\n| Admin            | `--color-admin`   |\n\nPAM, Agent Vault and Secret Scanning are product identities rather than\nadditional levels in the org/project hierarchy. Use `--color-product-pam`,\n`--color-product-av` and `--color-product-ss` when the current product calls\nfor them; `PageHeader` and the org sidebar map those project types to these\naccents, while `Button` exposes `pam`, `av` and `ss` variants. Check each component's supported variants instead of treating all\nscope and product variants as interchangeable.\n\n### Semantic colors\n\n| Intent  | Token             | Use                                          |\n| ------- | ----------------- | -------------------------------------------- |\n| Success | `--color-success` | Healthy states, completed rotations          |\n| Info    | `--color-info`    | Informational states, external documentation |\n| Warning | `--color-warning` | Attention-warranting states, stale items     |\n| Danger  | `--color-danger`  | Destructive actions, errors, expired access  |\n| Neutral | `--color-neutral` | Disabled, muted, \"empty\" states              |\n\n### Surface & chrome\n\n| Role              | Token                     |\n| ----------------- | ------------------------- |\n| Page canvas       | `--color-page`            |\n| Background        | `--color-background`      |\n| Foreground text   | `--color-foreground`      |\n| Card surface      | `--color-card`            |\n| Popover / Sheet   | `--color-popover`         |\n| Container         | `--color-container`       |\n| Container (hover) | `--color-container-hover` |\n| Border            | `--color-border`          |\n| Focus ring        | `--color-ring`            |\n| Accent text       | `--color-accent`          |\n| Muted text        | `--color-muted`           |\n| Label text        | `--color-label`           |\n\nThe `mineshaft-*` scale (50–900) is the underlying neutral ramp; see\n`index.css` for the full list. Prefer semantic tokens (`card`, `border`,\n`accent`) over raw mineshaft values.\n\n### Product-area accents (secret-manager)\n\nReserved for resource types in the secret management product:\n`--color-folder`, `--color-secret`, `--color-dynamic-secret`,\n`--color-import`, `--color-secret-rotation`, `--color-override`,\n`--color-proxied-service`.\nDo not repurpose these for generic UI.\n\n### Variant treatments\n\nChoose the component variant by semantic intent or actual scope/product, not\nby a preferred hue. Named semantic and scope tints commonly use these recipes:\n\n- **Badge** — `bg-<c>/15 border-<c>/10 text-<c>`, hover `bg-<c>/35`\n  (see [`Badge.tsx`](frontend/src/components/v3/generic/Badge/Badge.tsx))\n- **Button** — `bg-<c>/10 border-<c>/25 text-foreground`, hover `bg-<c>/15 border-<c>/30`\n  (see [`Button.tsx`](frontend/src/components/v3/generic/Button/Button.tsx))\n\n`Button` product variants (`pam`, `av`, `product`) use `/25` backgrounds and\n`/30` borders. Neutral/default, outline, ghost, shaded, and link treatments\nhave their own contracts; the tint recipes are not universal. Read the source\nfor exact classes and supported variants.\n\n## 3. Typography\n\nTypography uses three intentional families:\n\n- **IBM Plex Sans** (`font-sans`, `--font-sans`) is the default product UI face.\n  Body copy, forms, tables, buttons, labels, and navigation inherit it. Real\n  400, 500, 600, and 700 weights are bundled for UI hierarchy.\n- **Alliance No. 2** (`font-alliance`, `--font-alliance`) is the heading face.\n  Semantic headings and shared page, card, dialog, alert dialog, and sheet\n  titles use its bundled Regular weight. Do not use it for arbitrary bold body\n  text or request synthetic heavier weights.\n  Product and project tile names are titles, not descriptions. Use `CardTitle`\n  for them. Do not override semantic headings or shared titles with medium,\n  semibold, or bold weights; express hierarchy with size and spacing instead.\n- **JetBrains Mono** (`font-mono`, `--font-mono`) is the functional and\n  decorative mono. Use it for code, secret values, identifiers, timestamps,\n  logs, and short technical microcopy.\n\n| Role                    | Class                                                                     | Notes                                                              |\n| ----------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| Page title (h1)         | `font-alliance text-2xl font-normal underline underline-offset-4 decoration-<scope>/90` | In `PageHeader`; scope icon (size 26) sits inline before the title |\n| Page description        | `text-label`                                                              | Sits under the title, separated by the header's `gap-2`            |\n| Card title              | `font-alliance text-lg font-normal leading-none`                           | `flex gap-1.5` so badges can sit inline                            |\n| Card description        | `text-sm text-accent`                                                     |                                                                    |\n| Body                    | `text-sm`                                                                 | Default for table cells, form values, dialog content               |\n| Label / meta            | `text-xs text-accent`                                                     | Field labels, table column captions, metadata                      |\n| Field helper / feedback | `text-2xs leading-snug`                                                   | Defaults via `FieldDescription` and `FieldFeedback`                |\n| Badge                   | `text-xs` (auto, via `Badge`)                                             | Never override                                                     |\n| Button                  | `text-sm` (md/sm/lg), `text-xs` (xs)                                      | Auto via `Button` sizing                                           |\n\nOn brand-forward surfaces, a short phrase may use the `text-highlight`\nutility to create the solid project-yellow text block. Keep the phrase short,\nuse it as typographic emphasis rather than status or scope, and do not apply it\ninside routine product UI.\n\nSentence case for descriptions, helper text, and empty states. Title Case for\npage titles, card titles, dialog titles, sheet titles, button labels, badge\nlabels, and dropdown menu items. See §8 for voice rules on copy itself.\n\n## 4. Component Stylings\n\nPrefer v3 components from [`frontend/src/components/v3/`](frontend/src/components/v3)\nfor new UI. The v2 library is legacy; use it for new work only when v3 does not\ncover the required behavior. Existing v2 consumers marked blocked in the\n[lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) need\nconsumer-specific parity checks before replacement, even on mixed pages.\n\nFor exact tokens, classes, and supported variants, read component source and\nthe story when present; stories may not enumerate every implemented variant.\n\n### Reading the stories\n\nWhere present, stories may use these conventions:\n\n- **`Variant: X`** stories — one per prop-driven variant (e.g. `Variant: Outline`).\n- **`Example: X`** stories — composition recipes (e.g. `Example: With Header`,\n  `Example: Inside Card / Sheet / Dialog`).\n- Story descriptions or component-level metadata may explain when to use a composition.\n\nUse the closest example as a starting point, not a mandatory composition.\nSome stories use other names or omit descriptions; check component props,\nexports, and representative consumers when guidance is absent.\n\nRun Storybook with `cd frontend && npm run storybook` (port 6006) to preview.\n\n### Component inventory\n\nUse these tables to find the component for a given intent. For props,\nvariants, sizes, and class lists, open the source and any available story.\nThe [generic](frontend/src/components/v3/generic/index.ts) and\n[platform](frontend/src/components/v3/platform/index.ts) barrels also expose\nspecialized primitives not listed here (for example `CodeBlock`, `CopyButton`,\n`RadioGroup`, `TagsInput`, `FileDropzone`, `DurationInput`, `ColorPicker`).\n\n#### Actions\n\n| Component                                                                       | Reach for this when…                                                                                                                                                                                                                          |\n| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [`Button`](frontend/src/components/v3/generic/Button/Button.tsx)                | A text-bearing button — primary or secondary action.                                                                                                                                                                                          |\n| [`ButtonBadge`](frontend/src/components/v3/generic/Button/ButtonBadge.tsx)      | A short decorative flag attached to a Button corner—such as \"Last Used\", \"New\", or \"Recommended\". It inherits Badge typography, sizing, radius, border, and casing; only its corner positioning and solidified semantic tint are specialized. |\n| [`IconButton`](frontend/src/components/v3/generic/IconButton/IconButton.tsx)    | A square icon-only button — toolbars, row actions, compact triggers. Always `aria-label`.                                                                                                                                                     |\n| [`ButtonGroup`](frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx) | Visually join related controls — toolbars, segmented controls, split buttons, key-value chips.                                                                                                                                                |\n| [`Dropdown`](frontend/src/components/v3/generic/Dropdown/Dropdown.tsx)          | An action menu — overflow `⋯`, split-button alternates, contextual lists.                                                                                                                                                                     |\n\n#### Forms\n\n| Component                                                                                                                                 | Reach for this when…                                                                                        |\n| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| [`Field`](frontend/src/components/v3/generic/Field/Field.tsx)                                                                             | Compose labeled or validated form controls with the slots they need. `FieldContent`, description, and error are optional; a standalone filter/search control need not be a form field. |\n| [`Label`](frontend/src/components/v3/generic/Label/Label.tsx)                                                                             | Standalone form label outside a `Field`.                                                                    |\n| [`Input`](frontend/src/components/v3/generic/Input/Input.tsx) / [`TextArea`](frontend/src/components/v3/generic/TextArea/TextArea.tsx)    | Single-line / multi-line text entry.                                                                        |\n| [`InputGroup`](frontend/src/components/v3/generic/InputGroup/InputGroup.tsx)                                                              | Input with left/right addons — search bars, prefixed values.                                                |\n| [`Select`](frontend/src/components/v3/generic/Select/Select.tsx)                                                                          | Non-searchable single-select with a short, known option list.                                               |\n| [`Combobox`](frontend/src/components/v3/generic/Combobox/Combobox.tsx)                                                                    | Searchable single- or multi-select, including grouped/rich rows and inline or dialog creation when its contract fits. |\n| [`ReactSelect`](frontend/src/components/v3/generic/ReactSelect/index.ts)                                                                  | Deprecated compatibility for consumers needing react-select-specific overrides; see the lifecycle ledger. |\n| [`Toggle`](frontend/src/components/v3/generic/Toggle/Toggle.tsx) / [`Checkbox`](frontend/src/components/v3/generic/Checkbox/Checkbox.tsx) | Boolean toggle / multi-select boolean.                                                                      |\n| [`Calendar`](frontend/src/components/v3/generic/Calendar/Calendar.tsx)                                                                    | Date / multi-date / range picker primitive.                                                                 |\n| [`DateRangeFilter`](frontend/src/components/v3/platform/DateRangeFilter/DateRangeFilter.tsx)                                               | Platform date-range filter with presets — for filter bars.                                                  |\n| [`SecretInput`](frontend/src/components/v3/platform/SecretInput/SecretInput.tsx)                                                           | Platform secret-value editor with mask toggle and `${var}` highlighting.                                    |\n| [`PasswordGenerator`](frontend/src/components/v3/platform/PasswordGenerator/PasswordGenerator.tsx)                                         | Platform password generator against project secret-validation rules.                                        |\n| [`EmailInput`](frontend/src/components/v3/platform/EmailInput/EmailInput.tsx)                                                              | Platform chip field for one or more free-text email addresses. Validate it with `emailListSchema` from `@app/helpers/email`; check single addresses or picker-created invitees with `isValidEmail` rather than a local regex or `z.string().email()`. |\n\n#### Containers & overlays\n\n| Component                                                                       | Reach for this when…                                                                                                         |\n| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| [`Card`](frontend/src/components/v3/generic/Card/Card.tsx)                      | Default section container — tables, filters, forms, empty states all live in a Card.                                         |\n| [`Sheet`](frontend/src/components/v3/generic/Sheet/Sheet.tsx)                   | Panel for larger forms or workspaces; right is the usual side. Choose `size=\"form\"`, `\"wide\"`, or `\"workspace\"` by content, using the [size examples](frontend/src/components/v3/generic/Sheet/Sheet.stories.tsx); other sides serve specific flows. |\n| [`Dialog`](frontend/src/components/v3/generic/Dialog/Dialog.tsx)                | Centered modal — **use for small create/edit forms** (1–2 fields, single confirmation prompt) and short interactive prompts. |\n| [`AlertDialog`](frontend/src/components/v3/generic/AlertDialog/AlertDialog.tsx) | Confirm an action (destructive included). Replaces `confirm()`.                                                              |\n| [`Popover`](frontend/src/components/v3/generic/Popover/Popover.tsx)             | Anchored floating panel — filters, pickers, contextual UI.                                                                   |\n| [`Tooltip`](frontend/src/components/v3/generic/Tooltip/Tooltip.tsx)             | Small floating annotation on hover/focus.                                                                                    |\n| [`Accordion`](frontend/src/components/v3/generic/Accordion/Accordion.tsx)       | Collapsible sections.                                                                                                        |\n\n#### Data display\n\n| Component                                                                    | Reach for this when…                                                                                                  |\n| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| [`Table`](frontend/src/components/v3/generic/Table/Table.tsx)                | Read-mostly list of records with sortable columns. Pair with `Empty` + `Pagination`.                                  |\n| [`DataGrid`](frontend/src/components/v3/generic/DataGrid/data-grid.tsx)      | Editable spreadsheet-style grid — copy/paste, multi-cell selection, keyboard nav. Use only when `Table` isn't enough. |\n| [`Pagination`](frontend/src/components/v3/generic/Pagination/Pagination.tsx) | Page controls under a Table or list.                                                                                  |\n| [`Item`](frontend/src/components/v3/generic/Item/Item.tsx)                   | Vertically-stacked list rows with shared spacing — when a `Table` is too heavy.                                       |\n| [`Detail`](frontend/src/components/v3/generic/Detail/Detail.tsx)             | Read-only label/value pairs in a detail view.                                                                         |\n| [`Badge`](frontend/src/components/v3/generic/Badge/Badge.tsx)                | Small label or chip — status, scope tag, key/value pair.                                                              |\n| [`SelectedActionBar`](frontend/src/components/v3/generic/SelectedActionBar/SelectedActionBar.tsx) | Floating batch actions for a selected set of records. |\n\n#### Navigation & search\n\n| Component                                                                    | Reach for this when…                              |\n| ---------------------------------------------------------------------------- | ------------------------------------------------- |\n| [`Sidebar`](frontend/src/components/v3/generic/Sidebar/Sidebar.tsx)          | Scope-aware product navigation panel.             |\n| [`Breadcrumb`](frontend/src/components/v3/generic/Breadcrumb/Breadcrumb.tsx) | Hierarchical location trail at the top of a page. |\n| [`Command`](frontend/src/components/v3/generic/Command/Command.tsx)          | Search-driven command palette / typeahead list.   |\n| [`Tabs`](frontend/src/components/v3/generic/Tabs/Tabs.tsx)                   | Switch between related views in the same context. |\n| [`Stepper`](frontend/src/components/v3/generic/Stepper/Stepper.tsx)          | Show progress through a staged workflow.          |\n\n#### Feedback & loading\n\n| Component                                                                     | Reach for this when…                                             |\n| ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |\n| [`Alert`](frontend/src/components/v3/generic/Alert/Alert.tsx)                 | Inline message banner inside a page or Card.                     |\n| [`Toast`](frontend/src/components/v3/generic/Toast/Toast.tsx)                 | Transient post-action feedback. Replaces `alert()`.              |\n| [`Empty`](frontend/src/components/v3/generic/Empty/Empty.tsx)                 | Zero-state placeholder — pair with Table, list, or empty filter. |\n| [`Skeleton`](frontend/src/components/v3/generic/Skeleton/Skeleton.tsx)        | Shimmer placeholder while data is loading.                       |\n| [`Spinner`](frontend/src/components/v3/generic/Spinner/Spinner.tsx)           | Project-accented spinner by default; choose `xs`/`sm` for compact waits. |\n| [`Loader`](frontend/src/components/v3/generic/Loader/Loader.tsx)              | Branded loading animation — pending controls and page waits.     |\n| [`PageLoader`](frontend/src/components/v3/platform/PageLoader/PageLoader.tsx) | Centered Lottie spinner for full-page loading.                   |\n\n**Reduced motion.** When the operating system requests reduced motion, `Loader`\nand `PageLoader` hold the animation on its first frame instead of looping. The\nmark stays fully visible either way, because these Lotties animate only a\ntrim-path offset over an always-drawn mark. `role=\"status\"` is present in both\nstates, so the wait is still announced.\n\n#### Atoms & domain\n\n| Component                                                                                                         | Reach for this when…                                                                  |\n| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| [`Separator`](frontend/src/components/v3/generic/Separator/Separator.tsx)                                         | Horizontal/vertical divider.                                                          |\n| [`AccessRestrictedNotice`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedNotice.tsx) | Inline restriction for a section. |\n| [`AccessRestrictedDialog`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedDialog.tsx) | Page/tab access gate rendered in document flow, despite its name. |\n| [`ScopeIcons`](frontend/src/components/v3/platform/ScopeIcons.tsx)                                                | `OrgIcon` / `SubOrgIcon` / `ProjectIcon` / `InstanceIcon` — use when intent is scope. |\n| [`SecretManagerResources`](frontend/src/components/v3/platform/SecretManagerResources/SecretManagerResources.ts) | Canonical Secret Manager resource catalog (icon, color classes, name, slug, permission subject). |\n| [`ProjectPermissionSubjects`](frontend/src/components/v3/platform/ProjectPermissionSubjects/ProjectPermissionSubjects.ts) | Icon and tile color for every project policy subject (`ProjectPermissionSub`). |\n| [`PageHeader`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx)                                     | Canonical full-width page heading with scope semantics, description, and page actions. |\n| [`DocumentationLinkBadge`](frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.tsx) | Inline \"Documentation\" link badge in `CardTitle`.                                      |\n\n**Icons** — [`lucide-react`](https://lucide.dev). Sizing is bound by the\nhost component; don't override unless necessary.\n\n## 5. Layout Principles\n\n- **Page container** — `max-w-8xl` (88rem) centered, `bg-page`.\n- **Page header** — v3 `PageHeader` with scope/product icon + underlined `h1` + description. Import it from `@app/components/v3` and set `scope` to `org`, `namespace`, `instance`, or the applicable `ProjectType`. See [`PageHeader.tsx`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx).\n- **Header boundary** — `PageHeader` owns the normal 40px bottom margin (`mb-10`). Do not repeat that margin at ordinary callers or add a parent row gap between the header and body. Keep independent card/section spacing in the body layout. Use `className` for genuine compact/responsive cases, such as `mb-6 md:mb-10` for Overview; see the header's spacing stories.\n- **Section** — one `Card` per logical section. Title + optional `DocumentationLinkBadge` in `CardHeader`; primary action in `CardAction` (top-right).\n- **Tables inside Cards** — filters and search sit in the `CardHeader` above the table; pagination sits in the `CardFooter` or bottom of `CardContent`. **Empty state** — when the table has no rows (and isn't loading), hide the `Table` entirely and render `Empty` in its place; never leave a column header floating above a blank body. Add `className=\"border\"` to `Empty` whenever it's nested in a `Card`, `Sheet`, or `Dialog` so the dashed frame is visible against the parent surface (the component ships dashed-but-borderless on purpose for page-level use).\n- **Forms inside Sheets/Dialog** — create / edit flows open in a Sheet or Dialog, never inline, never as a full-page route. **Pick by form size:** small forms (1–2 fields, e.g. \"Add domain\", \"Rename\") go in a centered `Dialog`; large or multi-step forms (multiple fields, scrollable detail, file uploads, wizard steps) go in a right-side `Sheet`. When in doubt, default to Dialog — Sheet is for cases where Dialog feels cramped.\n- **Authentication form stacks** — use `CardContent className=\"flex flex-col gap-4\"` for the major vertical rhythm between fields, validation groups, and primary actions. Use a nested `flex flex-col gap-2` only for compact sets of equivalent actions such as social or SSO provider buttons. Use `FieldSeparator` between alternate authentication paths. Do not recreate sibling spacing with `mt-*`, `mb-*`, or `py-*`; keep a wrapper only when it owns animation, responsive visibility, conditional grouping, or tightly coupled control/error content.\n- **Spacing rhythm** — `gap-1.5` (intra-element), `gap-2 / gap-3` (adjacent elements), `p-4 / p-5` (section padding). Card = `p-5 gap-5`; Sheet header/footer = `p-4`.\n- **Sheet and Dialog footers** — actions sit right-aligned with the primary action last: `SheetFooter className=\"justify-end border-t\"` (`DialogFooter` is right-aligned by default), then Cancel (`ghost`), then the primary button. Back navigation in a multi-step flow sits on the left with `mr-auto`, and wizard footers with status text on the left use `justify-between`. A single full-width action needs no alignment class.\n\n## 6. Depth & Elevation\n\nDepth is conveyed primarily by layered surface tones and borders. The shared\nv3 `Card` includes `shadow-xs` by default; stronger shadows are reserved for\nelements that float (Popover, DropdownMenu, Sheet, SelectedActionBar).\n\n| Layer           | Surface                                  | Border                              |\n| --------------- | ---------------------------------------- | ----------------------------------- |\n| Page            | `bg-background`                          | —                                   |\n| Card            | `bg-card`                                | `border-border`                     |\n| Popover / Dropdown main menu | `bg-popover`               | `border-border` + `shadow-md`       |\n| Dropdown submenu | `bg-popover`                           | `border-border` + `shadow-lg`       |\n| Sheet           | `bg-popover`                             | `border-border` + `shadow-lg`       |\n| Floating bar    | `bg-popover`                             | `border-border` + `shadow-floating` |\n| Row hover       | `bg-container-hover`                     | —                                   |\n| Focus           | —                                        | Component-owned focus-visible treatment (Button/IconButton use `ring-2`; default Input uses `ring-[3px]`) |\n| Disabled        | `opacity-50 / 75`, `pointer-events-none` | —                                   |\n\nDo not add a shadow to a Card beyond its shared `shadow-xs` default, or to a\nTable row, standalone Badge, or `ButtonBadge`. An overlapping `ButtonBadge`\nuses its solidified semantic tint—not custom content styling or elevation—to\nremain legible across the control edge.\n\n## 7. Do's and Don'ts\n\n- **DO** choose Badge and Button variants by **intent** (danger / success /\n  warning / info / neutral), not by color preference.\n- **DO** use scope colors (`org`, `sub-org`, `project`, `admin`) to reinforce\n  hierarchy, and product colors (`pam`, `av`) for their own product surfaces.\n- **DO** mask secret values by default. Reveal must be an explicit user\n  action and should be logged.\n- **DO** put large create / edit forms in a right-side Sheet; smaller forms can be in Dialogs.\n- **DO** pair destructive confirmations with the resource name and the\n  consequence (see §9).\n- **DO** cite tokens (`bg-card`) over hex (`#xxxxxx`) in new code.\n- **DON'T** replace existing blocked v2 consumers based on matching component names alone; check the lifecycle ledger and interaction parity first.\n- **DON'T** add ad hoc box-shadows as a depth cue — borders and surface tones do\n  that work. The shared `Card` already has `shadow-xs`; floating elements\n  (Popover, DropdownMenu, Sheet, SelectedActionBar) include stronger shadows.\n- **DON'T** invent new colors. If it isn't in `index.css` `@theme`, it\n  doesn't belong.\n- **DON'T** use `project` yellow, `org` blue, or `sub-org` green as generic\n  accents. They are scope signals; repurposing them creates false hierarchy.\n  The documented brand-forward `text-highlight` treatment and deliberately\n  branded `ButtonBadge` flags are narrow exceptions for authentication and\n  onboarding surfaces.\n- **DON'T** mix font families arbitrarily. Use IBM Plex Sans for UI and body\n  text, Alliance Regular for headings, and JetBrains Mono (`font-mono`) for\n  monospaced content.\n- **DON'T** animate for decoration. Motion should clarify state change only.\n\n## 8. Voice & Content Tone\n\nCopy should read as if written by an engineer for another engineer: direct,\ntechnical, specific. The domain is serious — secrets, access, compliance —\nand the voice reflects that.\n\n### Stance\n\n- Direct. Active voice. Lead with the subject: \"Delete this role\" — not\n  \"This role will be deleted\".\n- Specific. Name the resource, the action, the consequence. Avoid vague\n  verbs (\"handle\", \"manage\") when a precise verb exists (`rotate`, `revoke`,\n  `import`).\n- Calm. No exclamation marks. No second-person cheer (\"Awesome!\",\n  \"You're all set!\"). No emoji.\n- Honest. Never claim speed, power, or ease in UI copy (\"seamless\",\n  \"powerful\", \"blazing-fast\"). Those belong on the marketing site, not here.\n\n### Shapes\n\n- **Labels & buttons** — Title Case, imperative: \"Add Secret\", \"Revoke\n  Access\", \"Rotate Key\".\n- **Title Case applies to** — buttons, badges, dropdown menu items, card\n  titles, dialog titles, sheet titles, page titles, tab labels, table column\n  headers. Anything that names a thing or an action is Title Case.\n- **Descriptions & helper text** — sentence case, one short sentence.\n- **Empty states** — state what's missing, then the next action:\n  \"No secrets yet. Add your first secret to get started.\"\n- **Errors** — name the failure and the remedy. Never \"Something went wrong\":\n  \"Could not rotate secret — token lacks `secrets:write` permission.\"\n- **Destructive confirmation** — name the resource and the consequence \"Delete \"API_KEY\" — this cannot be undone.\"\n- **Success toasts** — past tense, specific: \"Secret \"API_KEY\" created\".\n\n### Secrets & sensitive values\n\nNever include a secret's value in any user-visible copy — UI, logs, toasts,\nerrors, audit trails, or analytics. Refer to secrets by key only. Mask\ntokens and keys in screenshots and docs as well.\n\n### Documentation links\n\nUse `DocumentationLinkBadge` (info variant, external-link icon). Label it\n\"Documentation\" — not \"Learn more\", \"Read docs\", \"See more\".\n\n## 9. Agent Prompt Guide\n\nPasteable prompt fragments for AI coding agents producing new UI.\n\n**Before generating UI for any component:**\n\n1. Find the export in the v3 generic or platform barrel and read its source.\n2. If a sibling story exists, consult its examples and use-when guidance.\n3. Pick a supported variant by intent and current scope/product, not color preference.\n\n**Adding a section to an existing page:**\n\n> Wrap the section in a `Card` from `@app/components/v3`. Use `CardHeader`\n> with `CardTitle` + optional `CardDescription` + `CardAction` for the\n> top-right primary button (the variant for the current scope or product,\n> when supported). Put the table or content in `CardContent`.\n\n**A new create/edit form:**\n\n> Pick the container by form size. **Small forms (1–2 fields, e.g. \"Add\n> domain\", \"Rename\"):** centered `Dialog` (`Dialog`, `DialogContent`,\n> `DialogHeader` with `DialogTitle` + `DialogDescription`, `DialogFooter`\n> with the action buttons). **Large or multi-step forms (many fields,\n> scrollable detail, wizards):** right-side `Sheet` (`Sheet`, `SheetContent`,\n> `SheetHeader` with `SheetTitle` + `SheetDescription`, `SheetFooter` with\n> the action buttons). Use `react-hook-form` with a Zod resolver in both\n> cases. Compose labeled/validated inputs in `Field` with `FieldLabel` and\n> only the description, content layout, or feedback slots needed. Primary\n> button variant follows the current scope/product (`project`, `org`,\n> `sub-org`, `pam`, or `av`, when supported); cancel is `ghost`.\n\n**A status indicator:**\n\n> Use `Badge` from `@app/components/v3`. Pick the variant by intent:\n> `danger` for errors or expired access, `warning` for stale or\n> attention-warranting, `success` for healthy / completed, `info` for\n> informational, `neutral` for disabled / empty, `project` / `org` /\n> `sub-org` for scope references. Include a matching Lucide icon as the\n> first child.\n\n**A destructive confirmation:**\n\n> Use `AlertDialog`. Title: \"Delete `<resource-name>`\". Description: one\n> sentence naming the consequence, ending with \"This cannot be undone.\"\n> Confirm button is variant `danger`. Cancel button is variant `outline`.\n\n**Unsaved changes:**\n\n> Use `DiscardChangesAlertDialog` with `useDiscardChangesGuard`. Title:\n> \"Discard Changes?\". Description names what will be lost. Confirm is\n> Discard (`danger`); cancel is Keep Editing. Overlay editors intercept\n> close with `requestDiscard`. Do not use `window.confirm`.\n\n**A documentation link in a section:**\n\n> Use `DocumentationLinkBadge` from `@app/components/v3/platform`. Place it\n> in the `CardTitle` next to the section name.\n\n**Refer to:**\n\n- [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx) — canonical semantic reference for variant choice.\n- [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) — full-page composition reference (v3 PageHeader, Card-with-table, Create Secret Sheet, filters, DropdownMenu + ButtonGroup).\n- [`AccessControlPage`](frontend/src/pages/project/AccessControlPage) — full-page reference (permission-gated actions, `DocumentationLinkBadge`, role badges with `ClockAlertIcon` for expired access).\n- §8 above for any user-visible copy.\n\n## Appendix: Iteration Guide\n\n1. **Run Storybook** — `cd frontend && npm run storybook` (port 6006). Open\n   Badge, Button, Card, Table, Sheet first.\n2. **Read the two reference pages** — `OverviewPage` and `AccessControlPage`\n   render the full v3 vocabulary in production.\n3. **Tokens live in `index.css`** — `@theme` blocks and theme overrides. Never\n   introduce a hex that is not here.\n4. **Adding a variant** — extend the `cva()` block in the component and add\n   a story when appropriate. Match the existing variant family rather than\n   imposing a tint recipe on neutral, outline, or product variants.\n5. **Prefer v3 for new code** — use v2 only where needed for behavior not covered\n   by v3. Existing blocked v2 consumers migrate after lifecycle parity review.\n6. **Before merging** — `make reviewable-ui` (lint + type-check).\n7. **When in doubt** — mirror `OverviewPage`.\n",".agents/skills/docs-style/templates/README.md":"# Docs page templates\n\nEach template gives the section order, components, and fixed wording for one type of page.\n\n| Template | Use for | Lives at |\n| --- | --- | --- |\n| [app-connection.mdx](app-connection.mdx) | An app connection guide | `docs/integrations/app-connections/{product}.mdx` |\n| [secret-sync.mdx](secret-sync.mdx) | A secret sync guide | `docs/integrations/secret-syncs/{product}.mdx` |\n| [secret-rotation.mdx](secret-rotation.mdx) | A secret rotation guide | `docs/documentation/platform/secret-rotation/{rotation}.mdx` |\n| [quickstart.mdx](quickstart.mdx) | A quickstart or task guide that ends in a working result | Varies |\n| [landing-page.mdx](landing-page.mdx) | The overview page at the top of a docs section | `{section}/overview.mdx` |\n| [concept-page.mdx](concept-page.mdx) | A page about one concept and the tasks that involve it | Varies |\n\n## How to read a template\n\nEvery template uses the same four kinds of markup:\n\n| Markup | Meaning | What to do |\n| --- | --- | --- |\n| Plain text | Fixed wording | Keep the wording if the wording is true for this product. Check every claim in it against the product first |\n| `{Description}` | A placeholder | Replace the whole placeholder with your own text. The placeholder describes what goes there; it never contains text to copy |\n| `{/* IF: condition */}` ... `{/* END IF */}` | A conditional block | Keep the block, without the two markers, only if the condition is true. Otherwise delete the block |\n| `{/* NOTE: ... */}` | Guidance for the writer | Follow the guidance, then delete the comment |\n\nBraces inside a code span, such as `` `{{secretKey}}` ``, are literal text the product uses, not\nplaceholders.\n\nBefore you publish, delete every `{/* ... */}` comment and search the page for `{` to find any\nplaceholder you missed.\n\n## How closely to follow a template\n\nA template is the default structure for a page type, not a form to fill in:\n\n- **Leave out what the product doesn't have.** If a sync has no destination fields, or a\n  quickstart has no second sub-task, delete that part rather than writing filler for it\n- **Add what the product needs.** If the product needs a step or a section the template doesn't\n  have, add it where a reader would look for it (STYLE_GUIDE.md section 7)\n- **Check the fixed wording.** Fixed wording is true for most products of the type, not all of\n  them. Button labels, field names, and behavior claims such as \"The name must be\n  slug-friendly\" come from one version of the product, so check each one against the frontend\n  or backend before keeping it\n- **Write your own sentences.** The templates contain no example sentences on purpose. For what\n  a finished page looks like, read the example pages named at the top of each template, but\n  don't copy their sentences, which are about a different product\n\n## Shared snippets for an integration\n\nAn app connection page, a sync page, and a rotation page for the same product render the same\nsetup steps and form fields, so those live in snippets under\n`docs/snippets/app-connections/{product}/`:\n\n- `form-fields.mdx`: a bullet list of the connection form's fields, starting with **Name** and\n  **Description** (optional)\n- `{setup}.mdx`, named after the setup task in the third-party product: a `<Steps>` block for\n  that setup, with screenshots\n- `{setup}-for-rotation.mdx`: only when a rotation needs more setup than the connection does;\n  import the base snippet and add the extra steps rather than copying the base steps\n\nA link in `form-fields.mdx` to a step in the setup snippet resolves on whichever page renders\nboth snippets. Link to the `## Step 1:` heading that every page puts the setup snippet under,\nand give that heading the same text on the connection, sync, and rotation pages. Linking to a\nstep title doesn't work, because steps don't have a `title` prop.\n\nLinks from a sync or rotation page to the organization-level connection steps use\n`/integrations/app-connections/{product}#step-2-create-the-app-connection`.\n\n## Steps that depend on the integration\n\nWhen a connection needs different setup for each integration that uses it, such as a separate\npermission policy per integration, put a use case picker at the top of the page instead of a tab\nper integration. `snippets/UseCasePicker.jsx` exports two components:\n\n- `<UseCasePicker prefix=\"I want my {Product} connection to work for\" groups={...} />`: a\n  sentence ending in a dropdown where the reader picks one or more integrations\n- `<UseCase use=\"{id}\">`: shows its children only when the reader picked that id, or when they\n  picked nothing, so the page reads as the complete guide by default. `use` takes a\n  comma-separated list for content that several integrations share. Add `hideWhenEmpty` for\n  content that only makes sense next to a filtered result, such as setup notes that follow a\n  combined policy, so it stays hidden until the reader picks something\n\nA `<UseCase>` can hold its own `###` heading. Mintlify builds the \"On this page\" list at build\ntime, so it would still list hidden headings; the picker hides those entries at runtime, so the\nlist matches what's on the page\n\nPut the options in `snippets/app-connections/{product}/use-cases.jsx` as an exported array of\n`{ label, options: [{ id, label }] }` groups, import it on the page next to the component, and\npass it as `groups`. A snippet can't import another snippet, so the page does both imports. The\npicker writes the selection to the URL (`?use=id-1,id-2`), so a reader can share a link to a\nfiltered page.\n\nWrap each integration's content in its own `<UseCase>`, including its card in the\n\"Configure integrations\" step. `integrations/app-connections/aws.mdx` is the example.\n\n## Where the example pages differ from the templates\n\nThe example pages each template names predate some of the style guide's rules. When you read\nan example page, don't copy these:\n\n- Titled steps (`<Step title=\"...\" titleSize=\"h3\">`); the guide says to leave out `title`\n- Periods at the end of bullets\n- Em dashes, such as \"This guide doesn't configure any behavior—it just authenticates\"\n- Button labels in the 1Password and Databricks sync and rotation pages, which come from older\n  versions of the wizards (**Next** instead of **Continue**, **Disable Secret Deletion**\n  instead of **Prevent secret deletion**)\n",".agents/skills/docs-style/SKILL.md":"---\nname: docs-style\ndescription: Write, edit, and review Infisical documentation to the house style guide. Use whenever creating or changing any file under docs/ (.mdx pages, snippets, docs.json navigation), when asked to document a feature, write or update a guide, or review a docs diff or pull request. Covers running the Vale linter and the sentence-level review that Vale can't do.\n---\n\n# Infisical docs style\n\n`docs/STYLE_GUIDE.md` holds every rule for Infisical documentation. This skill doesn't repeat\nthe rules. This skill describes how to apply them.\n\n**Before you write or review anything, read all of `docs/STYLE_GUIDE.md` with the Read tool.**\nDon't work from a summary, a diff, or your memory of the guide. Each rule in the guide comes\nwith an \"Instead of\" and \"Write\" example, and the examples show what the rule means better\nthan any summary can.\n\n## Templates\n\nTo check if the page you're documenting has a template to follow, start from the matching file in\n[templates/](templates/README.md). The README says which template fits and how the snippets\nshared between integration pages work.\n\n## Before you write\n\nFind the facts yourself, in the code and the running product, before you draft anything. Don't\nask a question that the code can answer. Ask only about what the code can't tell you, such as\nwhy a feature exists or who it's for.\n\nCheck every fact against the product, not against other docs pages, because other docs pages\nare often out of date:\n\n- **UI steps:** Take button, tab, and field names and the order of screens from the running\n  product, or from the frontend source if you can't reach the running product\n- **Behavior claims:** Read the code path before you describe what the product does; if\n  another docs page seems to contradict the code, find out why before you edit either page\n\n## Reviewing\n\nReview your own drafts the same way you review anyone else's. Work in the following order:\ncut first, then check the structure, then check the sentences, then review the edited page\nagain. Rewriting a sentence is wasted work if the sentence shouldn't be on the page, or if it's\nin the wrong section.\n\n**1. Run the linter.** Run `make lint-docs-branch` from the repository root. Every rule except\n`Infisical.EmDashes` reports at error level, so any other finding fails the command, including\n`Infisical.UIActions` and `Infisical.Contractions`, the two rules most likely to report problems.\n`Infisical.EmDashes` reports at warning level and doesn't change the exit code, so also read the\nwarnings the command prints.\n\nVale doesn't check text indented four or more spaces inside a Mintlify component, and about\nhalf of the text in this repository is indented that way. If Vale reports no problems on a\npage with nested components, the nested text still needs the checks in steps 2, 4, and 5.\n\n**2. Cut what the reader won't act on.** Read the page one paragraph at a time. For each fact,\nask what the reader would do differently after reading it, as \"Leave out what the reader won't\nact on\" in section 2 of the guide describes. If the answer is nothing, the fact is a finding,\neven if it's true and you checked it in the code.\n\nNone of the other steps catches this. A sentence that shouldn't be on the page can still pass\nevery rule in section 5, and if you restructure it instead of cutting it, the page gets more\nheadings and lists without getting any shorter.\n\nApply this step to everything on the page: content that was already there, content someone else\nwrote, and your own draft. Treat each cut like any other finding.\n\n**3. Check the structure against sections 1, 4, 6, 7, 8, and 10 of the guide.** Check every\nsection of the page against each of those sections of the guide, including the page sections\nthat look fine at first glance.\n\n**4. Check every sentence against sections 3 and 5 of the guide.** Go through the sections one\n`###` heading at a time. Each heading names one rule, so for each heading, read every sentence\non the page and check whether the sentence breaks that rule. Then read every sentence out loud,\nas section 5 describes.\n\n**5. Check the formatting against section 11 of the guide.** No automated check covers any\nrule in section 11, so check each rule by reading the page.\n\nBefore you hand a draft back, check the draft for every mistake you've already been corrected\non, including corrections earlier in the same conversation.\n\n**6. Apply the findings.** Edit the page to fix every finding. Don't commit anything.\n\nIf the person you're working with wrote or edited the prose themselves in this conversation,\ndon't change their sentences yet. Report those findings in the format in step 8, and ask once\nwhether to apply them.\n\n**7. Review the edited page.** Run steps 1 to 5 again on the whole edited page, from the top.\nYour own edits create new problems. For example, turning part of a paragraph into a list can\nleave several short paragraphs in a row, and moving content can put a section out of order.\nFix what you find before you hand the page back.\n\n**8. Report what you changed.** Write one change per line: the location as\n`path/to/file.mdx:12`, the rule the change fixes, the text before the change, and the text\nafter it, or \"cut\" for a finding from step 2. Group the changes by file, and include the fixes\nfrom step 7.\n\n## When Vale is wrong\n\n`docs/CONTRIBUTING.MD` describes how to add a word to the vocabulary, enforce a spelling, and\nturn off a rule for one line with `{/* vale Infisical.RuleName = NO */}`. Turn off a rule only\non a specific line where Vale is actually wrong, never to hide problems across a whole page.\n"},"files":{"AGENTS.md":"## Feature Development\n\nCheck out CLAUDE.md for additional context on project file structure and general feature development.\n\n## Backend Development\n\n**Read the [Backend Code Quality Guide](backend/CODE_QUALITY.md) for any change under `backend/`, and check your work against it before reporting the task done.** Features, refactors, bug fixes, and reviews all count.\n\nIt is a short, deliberately non-exhaustive floor: error messages users can understand (and no pointless 500s), validation on every API input, correct pagination when calling third-party APIs, no deadlock conditions on a small connection pool, REST-aligned API interfaces, and audit log events an admin can actually read.\n\nTreat that list as a summary of the guide's current contents, not as a condition for reading it. A change that does not look like any of those topics still gets checked, because the guide grows and because the items apply in places they are not obviously about (a bug fix that adds a query inside an existing transaction, a refactor that moves a third-party list call).\n\nSome of it needs judgment rather than a mechanical check. The deadlock rules cannot be caught by testing one request at a time. And a design that breaks REST should be raised with the author, with the conforming alternative proposed, rather than implemented silently or quietly \"fixed\" (some deviations are deliberate).\n\n## Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md), including the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/` (or `make lint-docs` for the whole site; the `Check docs style` CI check runs the branch variant). It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide. A clean run is not a substitute for reading the guide: the judgment calls it cannot check are the ones that matter most. Vale cannot see prose indented inside Mintlify components, so a clean run is not evidence that nested content was checked, and `Infisical.EmDashes` reports at warning level, so its findings never change the exit code.\n\n## UI Development\n\nWhen building frontend UI, follow [DESIGN.md](DESIGN.md) for the v3 design system — colors, typography, components, and voice.\n\nBefore changing a shared frontend component's lifecycle, read the [shared component lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) for current deprecations, blockers, and replacement decisions.\n\nPickers for entities a user may need to create first (app connections, gateways, and similar) must offer an inline \"Create new X\" row, gated on the create permission, so the user never has to leave the flow. Build them with the v3 `Combobox` and its `creation` prop, which renders that row; `Combobox` replaces both the deprecated `FilterableSelect` and `CreatableSelect`. For app connections, use `creation.mode = \"dialog\"` and have `renderForm` render `AppConnectionForm` with the app preset, selecting the new connection and calling `complete` in its `onComplete`, and route `onDismiss` and the form's cancel through `useDiscardChangesGuard` (see `CaInstallCertModal/useConnectionCreation.tsx`). A v2 `Modal` parent sits at `z-[60]`, above v3 overlays, so migrate it to the v3 `Dialog` when it hosts one (see `CaInstallCertModal/CaInstallCertModal.tsx`).\n\n## Issue and PR Guidelines\n\n- Never create a GitHub issue.\n- When creating a pull request, use and fully complete the repository's\n  `.github/pull_request_template.md` template.\n\n## Workflow Guardrails\n\n- Do not reformat existing code as incidental cleanup. Preserve the repository's current formatting unless a review agent, CI check, or user explicitly asks for a formatting change.\n- Never push changes to `license-fns` or local UI fixtures to a remote branch. Those edits are local-only support for review and test passes; keep them out of commits and pull requests.\n- When resolving merge conflicts, optimize for a correct, minimal, strategically unblocking resolution. Do not let a slow or stalled local check block the push when the corresponding frontend or backend CI checks on GitHub can validate the branch; push the resolved code and use CI to complete the expensive verification.\n","CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Essential Commands\n\n- `make reviewable-api` / `make reviewable-ui` — lint:fix + type:check (run before PRs). Neither checks [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md); review backend changes against it yourself.\n- `cd backend && npm run migration:new` — create new DB migration\n- `cd backend && npm run generate:schema` — regenerate Zod types from DB after migration changes\n- `make test-api-unit` / `make test-api-e2e` — run the Node backend suites in the FIPS image CI uses, against a throwaway database. Narrow with `SPEC=<pattern>`. See `backend/CLAUDE.md` for why running `npm run test:e2e` directly can destroy your dev data.\n- `cd backend-go && make test` — run Go integration tests\n\nBoth backend and frontend use `@app/*` as path alias to `./src/*`.\n\n## Repository Structure\n\nInfisical is an open-source secret management platform. Monorepo layout:\n\n```\ninfisical/\n├── backend/               # Fastify 4 API server (see backend/CLAUDE.md)\n├── backend-go/            # Go API server — partial rewrite (see backend-go/CLAUDE.md)\n├── frontend/              # React 19 SPA (see frontend/CLAUDE.md)\n├── wasm/                  # Rust crates compiled to WASM for the frontend (see wasm/<crate>/CLAUDE.md)\n├── e2e/                   # External Playwright suite — gates prod deploys against gamma (see e2e/CLAUDE.md)\n├── tests/                 # Go blackbox suite — real containers, HTTP only (see tests/CLAUDE.md)\n├── docs/                  # Documentation site (Mintlify-based)\n├── build-versions.env            # Versions pinned across several Dockerfiles (see Dependency Policy)\n├── docker-compose.dev.yml        # Local dev (PostgreSQL, Redis, backend, frontend, Nginx)\n├── docker-compose.prod.yml       # Production deployment stack\n├── docker-compose.bdd.yml        # BDD testing environment\n├── docker-compose.e2e-dbs.yml    # E2E test databases (Oracle, SAP, Snowflake, etc.)\n├── Dockerfile.standalone-infisical       # Standalone image (frontend + backend)\n├── Dockerfile.fips.standalone-infisical  # FIPS-compliant standalone image\n├── .github/               # CI workflows, PR template\n└── CLAUDE.md               # This file\n```\n\n- **`backend/`** — Fastify 4 API server, TypeScript, PostgreSQL via Knex, BullMQ queues. See [`backend/CLAUDE.md`](backend/CLAUDE.md) for architecture, patterns, and commands.\n- **`backend-go/`** — Go API server (partial rewrite), chi + chita framework, raw pgx queries, same PostgreSQL database. See [`backend-go/CLAUDE.md`](backend-go/CLAUDE.md) for architecture, patterns, and commands.\n- **`frontend/`** — React 19 SPA, Vite 8, TanStack Router + React Query, Tailwind CSS v4. See [`frontend/CLAUDE.md`](frontend/CLAUDE.md) for architecture, patterns, and commands.\n- **`wasm/`** — Rust crates that compile to WASM for the frontend. Generated bindings are committed under `frontend/src/lib/<crate>/` so the frontend builds without a Rust toolchain. Each crate has its own `CLAUDE.md` with the rebuild command (e.g. [`wasm/ironrdp-decoder/CLAUDE.md`](wasm/ironrdp-decoder/CLAUDE.md)) — run it after any change to that crate's `src/` or `Cargo.toml` so source and bindings stay in sync.\n- **`docs/`** — Product documentation site. Has its own Dockerfile for building. Reference docs for up-to-date feature descriptions and API usage.\n- **`e2e/`** — Playwright suite that runs against a deployed environment (gamma) between deploy and prod promotion. Distinct from `backend/e2e-test/` (in-process Vitest). Failure blocks every prod-deploy job. Covers SCIM + SAML flows (SP-initiated, IdP-initiated, deactivation, response rejection) against a mock IdP we control — see [`e2e/CLAUDE.md`](e2e/CLAUDE.md) for the harness and the one-time gamma bootstrap.\n\n- **`tests/`** — Go blackbox suite. Boots a real Infisical in Docker and talks to it over HTTP only, so the same tests run against the Node and Go servers unchanged. Distinct from `backend/e2e-test/` (in-process Vitest, compiled against the Node source) and from `e2e/` (Playwright against a deployed gamma). `make test-suites` from `tests/` runs everything; see [`tests/CLAUDE.md`](tests/CLAUDE.md) before writing one.\n\nEnterprise features live in `backend/src/ee/` (services and routes), registered before community routes so they can override/extend them.\n\n### Self-Hosted Deployment\n\nInfisical supports self-hosted deployment via Docker. Key considerations:\n- **`Dockerfile.standalone-infisical`** — single-container image with both frontend and backend; used for simple deployments.\n- **`Dockerfile.fips.standalone-infisical`** — FIPS 140-2 compliant variant for regulated environments. Be strict about not introducing dependencies that break FIPS compliance.\n- **`docker-compose.prod.yml`** — production compose with backend, PostgreSQL, and Redis.\n- New backend dependencies should be evaluated carefully — they affect container size, FIPS compliance, and the encryption boundary. Check `docs/` for self-hosted deployment documentation when in doubt.\n\n### Dependency Policy\n\n`build-versions.env` holds versions that several Dockerfiles install, currently the bundled\nInfisical CLI. Each Dockerfile keeps a matching `ARG` default so external builders (Northflank,\nRender, a plain `docker build`) work without passing anything, and CI and compose override it from\nthat file. `check-dockerfile-pins.yml` fails a PR when the defaults drift from it, so change the\nfile and the `ARG` defaults together.\n\nBoth `backend/` and `frontend/` enforce a minimum release age of 7 days for npm packages (configured via `.npmrc` in each directory). This means `npm install` will only resolve package versions published at least 7 days ago, as a supply-chain security measure.\n\nThe backend installs with the npm bundled in Node 26 (11.x), which skips dependency install scripts unless `allowScripts` in `backend/package.json` approves them. The install still succeeds when a script is skipped, so a native addon that was not approved fails only when it is first `require`d. When adding or bumping a dependency with an install script, run `npm install-scripts ls` and approve or deny what it lists (`npm install-scripts approve <pkg>`).\n\n## Cross-Cutting Patterns\n\n### Backend Code Quality\n\n**Read [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md) for every backend change, and check the change against it before calling the work done.** This applies to all work under `backend/`: new features, refactors, bug fixes, and reviews alike.\n\nIt is a floor, not an exhaustive standard: user-understandable error messages and no pointless 500s, explicit validation on every API input, correct pagination when calling third-party APIs, avoiding deadlock conditions on a small connection pool (thread `tx`, keep transactions short), REST-aligned API interfaces (flag deviations for the author to confirm rather than implementing them silently), and audit log events an admin can actually read.\n\nThat list describes what the guide currently covers; it is **not** a test for whether the guide applies. Do not skip it because a change does not look like one of those topics. Read it, then decide which items are relevant.\n\n### Code Comments\n\n**Default to no comments.** One earns its place only by explaining *why*: a non-obvious constraint, a workaround, an ordering dependency, or logic that looks wrong until you know the reason.\n\nNever write: narration restating the next line; section headers inside a function (`// --- validation ---`); change history (`// Added retry logic`, `// NEW`); references to plans, tickets, PRs, or reviewers; docstrings restating the signature; commented-out code.\n\nBefore finishing, delete any comment you added that only says what the code says.\n\n### Product Analytics\n\nRead [`ANALYTICS.md`](ANALYTICS.md) before adding or changing product analytics. Every event has one owning producer: the frontend records UI exposure and intent, the application backend records accepted commands it can establish, and the system of record records durable outcomes. Define stable dimensions, cardinality, firing conditions, and dashboard consumers before implementation.\n\n### Design System & Voice\n\nThe v3 visual system (colors, typography, components, layout) and product voice/content tone are documented in [`DESIGN.md`](DESIGN.md). Read it before producing new UI or user-visible copy.\n\n### Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md): what belongs on a page, and the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/`. It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide: sentence case in headings and frontmatter titles, product and vendor spellings, spelling against a curated vocabulary, `$` prompts in code blocks, and em dash density. See [docs/CONTRIBUTING.MD](docs/CONTRIBUTING.MD) for how to extend the vocabulary or suppress a rule. It lints only the `.mdx` files the branch touched; `make lint-docs` checks every page. The `Check docs style` GitHub workflow runs that same script, so local and CI agree by construction. Every rule except `Infisical.EmDashes` reports at error level, including `Infisical.UIActions` (click/tap where the verb should be select) and `Infisical.Contractions`, so the run fails if either one reports a finding. `Infisical.EmDashes` reports at warning level and never changes the exit code -- read the printed output, not just the status. Note that Vale cannot see prose indented four or more spaces inside components -- roughly half of this repo -- and reports nothing about what it skipped, so a clean run does not mean a nested page was checked.\n\n### Auth & Permissions\n\nAuth modes (JWT, IDENTITY_ACCESS_TOKEN, SCIM_TOKEN) are extracted in `backend/src/server/plugins/auth/`. Authorization uses CASL (`@casl/ability`) with project-level and org-level permission checks — see `backend/CLAUDE.md` for backend details and `frontend/CLAUDE.md` for frontend permission hooks/HOCs. Note: `API_KEY` and `SERVICE_TOKEN` auth modes are deprecated — do not use them in new code.\n\n### Org-Scoped Products\n\nPAM and Agent Vault are products over one implicit project per organization, not projects a user creates. Their URLs are `/organizations/$orgId/<product>/…` with no `$projectId`, the frontend resolves the project from the org (`useImplicitProjectId`), the backend bootstraps it lazily and blocks generic create and delete, their roles are admin or member only, and each has its own metered identities dimension (`pam_identities`, `agent_vault_identities`). When you add a `ProjectType.PAM` arm anywhere, add the Agent Vault arm beside it. See `backend/src/ee/services/pam/CLAUDE.md` and `backend/src/ee/services/agent-vault/CLAUDE.md`.\n\n### Single-Project Products\n\nSecret Scanning has one active project per organization (the newest). Like Certificate Manager, its URLs are `/organizations/$orgId/secret-scanning/…` with no `$projectId`; legacy orgs can still hold several projects, so the frontend pins the one being viewed (see `frontend/CLAUDE.md`). New orgs and sub-orgs get it at creation (`bootstrapSecretScanningProject`), older orgs lazily from `GET /api/v2/secret-scanning/project`, and `projectId` is optional on its API (`inject-secret-scanning-project-id`). Do not add a project picker or a way to create more Secret Scanning projects.\n\n### Service Factory + Manual DI (Backend)\n\nNo IoC container in either backend. Every service is a factory function with explicit dependencies.\n- **Node.js**: Wired in `backend/src/server/routes/index.ts` — see `backend/CLAUDE.md`.\n- **Go**: Wired in `backend-go/internal/server/api/api.go` via `NewRegistry()` — see `backend-go/CLAUDE.md`.\n\n### Interface Pattern (Go)\n\nBoth handlers and services define narrow interfaces for their dependencies (consumer-defined interfaces). Only expose methods or fields that are needed — keep everything else private. This enables testability and loose coupling.\n\n### Alerting\n\nAll user-facing \"notify me when X happens\" features share one module: `backend/src/services/alert/`. It owns the alert CRUD, the channel stack (email, Slack, webhook, PagerDuty), recipients, dedup, history, and dispatch. To alert on a new resource, register an `IResourceAlertProvider` on the shared registry — do not stand up a per-domain alert service, channel table, or notification cron. See `backend/CLAUDE.md` for the provider contract and invariants.\n\n**If you touch a code path that deletes or detaches an alertable resource, it must reap that resource's alerts.** `alerts.resourceId` has no foreign key, so nothing cascades and the alert is left dangling. Use `alertService.deleteAlertsForDeletedResource` when the row is gone (unscoped, reaps across every org) and `deleteAlertsForResource` when the resource only left a scope. See the alerting invariants in `backend/CLAUDE.md`.\n\n### Approvals\n\nAll \"someone has to approve this before it happens\" features share one module:\n`backend/src/services/approval-policy/`, which owns policies, requests, steps, grants, break-glass, and\nthe generic `/v1/<policy-type>/approval-policies` endpoints. Current types: PAM access, certificate\nrequests, code signing.\n\nA policy type plugs in through **one** extension point, a `TApprovalResource` (see its doc comment),\nconstructed with its own dependencies and registered in the `approvalResources` map in\n`server/routes/index.ts`; that also gives it the generic `/v1/approval-policies/<type>/...` endpoints,\nwhich every type now uses for policy CRUD, requests, approve, reject and grant revocation. **Do not stand up a per-product approval service, request table, or review\nendpoint**: `access-approval-*` and `secret-approval-*` predate this module and are what we are\nconverging away from. A product-facing service on top of it (PAM's `pam-access-request-service.ts`) is\nfor product vocabulary only: resolving the resource a request names, notifications, and read models.\nAny rule about what is *allowed* belongs in the resource, or the product's own API and the shared API\nstop agreeing.\n\n### API Layer (Frontend)\n\nReact Query + Axios with query key factories per domain. Each API domain in `frontend/src/hooks/api/` has `queries.tsx`, `mutations.tsx`, and `types.tsx` — see `frontend/CLAUDE.md` for conventions.\n\n## Keeping CLAUDE.md Up to Date\n\nWhen making significant changes to the codebase (new services, architectural shifts, new patterns, major refactors), update the relevant CLAUDE.md file(s) with high-level findings. This includes this root file for cross-cutting concerns, `backend/CLAUDE.md` for Node.js backend changes, `backend-go/CLAUDE.md` for Go backend changes, and `frontend/CLAUDE.md` for frontend changes. The goal is to keep these files accurate as living documentation so future sessions start with correct context.\n\n## Wiring a New Full-Stack Feature\n\n1. **Backend**: Create service module, migration, wire DI, add routes — see checklist in `backend/CLAUDE.md`\n2. **Frontend**: Add API hooks in `src/hooks/api/<domain>/`, create page/view, wire route — see `frontend/CLAUDE.md` for routing and component patterns\n3. Check the backend work against [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md)\n4. Run `make reviewable-api` and `make reviewable-ui` before submitting\n\n## Helpful files\n\nClaude Code reads CLAUDE.md, not AGENTS.md, so the shared agent instructions are imported here rather than linked:\n\n@AGENTS.md\n","backend-go/CLAUDE.md":"# backend-go\n\nPartial Go rewrite of the Node.js backend using chi + oapi-codegen + raw pgx queries.\n\n## Commands\n\n```\nmake build            # build binary\nmake dev              # hot-reload via docker-compose + air\nmake test             # run all tests (unit + integration)\nmake test-unit        # unit tests only (./internal/...)\nmake test-integration # integration tests only (./tests/..., testcontainers)\nmake test-hsm         # HSM tests (requires Docker, builds test container)\nmake lint             # must pass before submission\nmake lint-fix         # auto-fix linting issues\nmake generate         # run go generate ./... for oapi-codegen\n```\n\n## Code Rules\n\n- **Linting**: `make lint` must pass. No `//nolint` without justification.\n- **Service constructor**: `(ctx context.Context, logger *slog.Logger, deps *Deps)`. Deps struct ends in `Deps`, passed by pointer. Every I/O method takes `ctx` first.\n- **Logger**: pass `*slog.Logger` via constructor — never `slog.Default()`.\n- **Interfaces**: consumer-defined. Accept interfaces, not concrete types.\n- **Visibility**: expose only what's needed.\n- **No DAL layer**: services use `pg.DB` directly with raw pgx.\n- **Error wrapping**: wrap DB errors at service level with context:\n  ```go\n  errutil.DatabaseErr(\"Failed to load\").WithErrf(\"FuncName(arg=%s): %w\", arg, err)\n  errutil.Forbidden(\"Access denied\").WithErrf(\"FuncName: permission check failed\")\n  ```\n- **Lean code**: inline single-use helpers.\n- **Test naming**: `Test<FunctionName>_<Scenario>`.\n\n## Architecture\n\n```\ncmd/infisical/main.go           # Entry point\ninternal/\n├── config/                     # Env config via koanf\n├── database/\n│   ├── pg/                     # DB interface (primary + replicas via pgxpool)\n│   │   ├── qb/                 # Query builders (Where, Insert, Update, Delete)\n│   │   ├── sqln/               # SQL nesting (GroupRows for LEFT JOIN flattening)\n│   │   └── pglock/             # PostgreSQL advisory locks\n│   └── redis/                  # Redis client\n├── libs/                       # crypto, errutil, fn, logutil\n├── server/\n│   ├── api/                    # Endpoint implementations + DI wiring\n│   │   ├── shared/             # Shared types (Error, ValidationError) + ErrorHandler\n│   │   ├── platform/           # Platform handlers (projects, etc.)\n│   │   └── secretmanager/      # Secret manager handlers\n│   └── middlewares/            # HTTP middlewares (RequireAuth, etc.)\n├── services/                   # Shared business logic (auth, permission, kms, ...)\n└── keystore/                   # Redis key-value operations\ntests/                          # Integration tests (external test packages)\n├── infra/                      # Test infrastructure (testcontainers, helpers)\n├── platform/                   # Platform service tests\n│   ├── auth/                   # Authentication handler tests\n│   ├── externalkms/            # External KMS (AWS/GCP) tests\n│   ├── hsm/                    # HSM tests (container-based)\n│   ├── kms/                    # KMS service tests\n│   ├── permission/             # Permission system tests\n│   ├── projects/               # Projects handler tests\n│   └── ratelimit/              # Rate limiting tests\n└── secretmanager/\n    └── secrets/                # Secrets API tests (list, get, permissions)\n```\n\n**Two tiers:**\n- `server/api/` — DI wiring + endpoint implementations. Handlers 1:1 with endpoints.\n- `services/` — Business logic, uses `pg.DB` directly.\n\n### Wiring (Composition Root)\n\nAll service/handler initialization lives in `server/api/`:\n\n```\nserver/api/\n├── api.go                         # Infra, Registry, NewRegistry()\n├── platform_services.go           # newPlatformServices() — kms, auth, permission, etc.\n├── secretmanager_services.go      # newSecretManagerServices() — secret, folder, import\n├── platform_routes.go             # RegisterPlatformRoutes() — chi routing\n├── secretmanager_routes.go        # RegisterSecretManagerRoutes() — chi routing\n├── shared/                        # Shared types + error handler\n├── platform/<handler>/            # Handler implementations\n└── secretmanager/<handler>/       # Handler implementations\n```\n\nInitialize as local variables first, then assign to struct fields.\n\n### Handler Package Structure\n\nEach handler package is generated from an OpenAPI spec using `oapi-codegen`:\n\n```\nserver/api/<product>/<handler>/\n├── openapi.yml    # OpenAPI 3.0 spec (source of truth)\n├── cfg.yaml       # oapi-codegen config\n├── gen.go         # GENERATED types, HTTPAdapter, ServiceInterface\n├── <name>.go      # Handler struct + NewHandler() + //go:generate directive\n└── <endpoint>.go  # One handler method per file (list_secrets.go, get_secret.go, ...)\n```\n\n### Handler Pattern\n\nUses `oapi-codegen` (DoorDash fork) to generate types and HTTP adapters from OpenAPI specs:\n\n**`openapi.yml`** — OpenAPI 3.0 source of truth:\n```yaml\npaths:\n  /api/v4/secrets:\n    get:\n      operationId: ListSecretsV4\n      summary: List secrets\n      parameters:\n        - name: projectId\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: environment\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: secretPath\n          in: query\n          schema:\n            type: string\n      responses:\n        '200':\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ListSecretsV4Response'\n```\n\n**`<handler>.go`** — Handler implements generated `ServiceInterface`:\n```go\n//go:generate go tool oapi-codegen -config cfg.yaml openapi.yml\n\npackage secret\n\nvar _ ServiceInterface = (*Handler)(nil)\n\ntype Handler struct {\n    logger     *slog.Logger\n    permission PermissionService\n    secrets    SecretsService\n}\n\nfunc NewHandler(deps *Deps) *Handler {\n    return &Handler{...}\n}\n\n// ListSecretsV4 implements ServiceInterface\nfunc (h *Handler) ListSecretsV4(ctx context.Context, opts *ListSecretsV4ServiceRequestOptions) (*ListSecretsV4ResponseData, error) {\n    q := opts.Query\n    secretPath := fn.ValueOr(q.SecretPath, \"/\")  // Use helper for optional fields\n    \n    // ... business logic ...\n    \n    return NewListSecretsV4ResponseData(&ListSecretsV4Response{\n        Secrets: secrets,\n    }), nil\n}\n```\n\n**Generated types:**\n- Required fields: plain types (`string`, `int`, etc.)\n- Optional fields: pointers (`*string`, `*int`, etc.)\n- Use `fn.ValueOr(ptr, default)` for safe access with fallback\n- Use `new(value)` to create pointers for optional fields (Go 1.26+)\n\n**Route registration** (`<product>_routes.go`):\n```go\nfunc RegisterSecretManagerRoutes(router chi.Router, logger *slog.Logger, platform *PlatformServices, svc *SecretManagerServices) {\n    secretsHandler := secret.NewHandler(&secret.Deps{...})\n    secretsAdapter := secret.NewHTTPAdapter(secretsHandler, shared.NewErrorHandler(logger))\n\n    router.Group(func(r chi.Router) {\n        r.Use(middlewares.RequireAuth(\n            platform.Authenticator,\n            middlewares.JWTAuth,\n            middlewares.IdentityAccessTokenAuth,\n            middlewares.ServiceTokenAuth,\n        ))\n        r.Get(\"/api/v4/secrets\", secretsAdapter.ListSecretsV4)\n        r.Get(\"/api/v4/secrets/{secretName}\", secretsAdapter.GetSecretByNameV4)\n    })\n}\n```\n\n### Auth Middleware\n\n`RequireAuth` is a fail-closed middleware that validates bearer tokens:\n\n```go\nr.Use(middlewares.RequireAuth(\n    authenticator,\n    middlewares.JWTAuth,              // User session JWT\n    middlewares.IdentityAccessTokenAuth,  // Machine identity token\n    middlewares.ServiceTokenAuth,     // Service token\n))\n```\n\n- No bearer header → 401\n- Bearer present but mode not in allowed list → 401\n- Validator returns error → 401\n- Success: identity stored in context via `auth.WithIdentity()`\n\n### Error Handling\n\n`shared.ErrorHandler` translates errors to HTTP responses:\n- `errutil.Error` → appropriate status + JSON body\n- `runtime.ValidationErrors` → 400 with field errors\n- Unknown errors → 500\n\nAll errors include request ID and are logged appropriately (warn for 4xx, error for 5xx).\n\n### Handler vs Service Responsibilities\n\n**Handlers** (`server/api/`) — thin orchestration:\n1. Extract identity from context\n2. Resolve permissions + IDs (e.g., project ID from slug) via shared services\n3. Call domain service with opts (including `AccessChecker` for permission filtering)\n4. Build API response + audit logs\n\n**Services** (`services/`) — domain logic: data fetching/aggregation, decryption/encryption, business rules (import chaining, secret expansion, personal overrides), and permission filtering via `AccessChecker` in opts.\n\n**AccessChecker pattern**: services accept an optional `AccessChecker` interface in opts. Pass `nil` to skip permission checks (internal/integration use). Permission logic stays in handlers; services enforce it.\n\n### Permission Checker Pattern\n\nPermission checks use `gocasl`. Instead of calling `gocasl.Can()` directly in handlers/services, wrap each domain's checks in a dedicated checker type under `services/permission/<product>/`:\n\n```go\n// services/permission/project/secret_permission.go\ntype SecretAccessChecker struct {\n    ability *gocasl.Ability\n}\n\nfunc NewSecretAccessChecker(ability *gocasl.Ability) *SecretAccessChecker {\n    return &SecretAccessChecker{ability: ability}\n}\n\nfunc (c *SecretAccessChecker) CanReadSecretValue(env, path, key string, tags []string) bool {\n    return gocasl.Can(c.ability, project.SecretActionReadValue, project.SecretSubject{\n        Environment: env, SecretPath: path, SecretName: key, SecretTags: tags,\n    })\n}\n```\n\nUsage:\n\n```go\npermResult, _ := permissionSvc.GetProjectPermission(ctx, args)\nchecker := project.NewSecretAccessChecker(permResult.Permission.Ability)\nif !checker.CanReadSecretValue(env, path, key, tags) {\n    return errutil.Forbidden(\"Cannot read secret\")\n}\n```\n\n**Why**: keeps subject construction and action selection in one place per domain, makes checkers easy to mock in tests, and prevents handler/service code from depending on `gocasl` directly. Existing checkers: `SecretAccessChecker`, `AssumePrivilegeChecker`.\n\nActions and subjects live in `services/permission/project/{actions.go,subjects.go}`:\n- `gocasl.DefineAction[SubjectType](\"verb\")` — typed action constants\n- Subject structs implement `SubjectType() string` and `GetField(string) any` so CASL can match field-conditional rules\n\n**Database access**: services receive `pg.DB` and execute raw pgx queries via helper packages. Use `db.Primary()` for writes, `db.Replica()` for reads.\n\n### Query Helpers\n\n**`qb`** — dynamic SQL builders:\n\n```go\n// WHERE (named args)\nargs := pgx.NamedArgs{\"folderID\": folderID}\nwhere := qb.NewWhere().\n    Add(\"folder_id = @folderID\").\n    AddIf(len(keys) > 0, \"key = ANY(@keys)\")\nargs[\"keys\"] = keys\nrows, _ := db.Replica().Query(ctx, `SELECT * FROM secrets WHERE `+where.String(), args)\n\n// INSERT / UPDATE / DELETE\nsql, args := qb.Insert(\"secrets\", \"id\", \"key\", \"folder_id\").\n    Values(id1, key1, folder1).Values(id2, key2, folder2).Returning(\"*\").Build()\nsql, args := qb.Update(\"secrets\").Set(\"key\", newKey).Where(\"id = @id\", \"id\", id).Returning(\"*\").Build()\nsql, args := qb.Delete(\"secrets\").Where(\"id = @id\", \"id\", id).Build()\n```\n\n**`sqln`** — flatten LEFT JOIN rows into nested structs:\n\n```go\nsecrets := sqln.GroupRows(flatSecrets, sqln.Grouper[Secret, uuid.UUID]{\n    Key:   func(s *Secret) uuid.UUID { return s.ID },\n    Merge: func(existing, row *Secret) {\n        if len(row.Tags) > 0 {\n            existing.Tags = fn.AppendUnique(existing.Tags, row.Tags[0],\n                func(t Tag) uuid.UUID { return t.ID })\n        }\n    },\n})\n```\n\n**`pglock`** — PostgreSQL advisory locks:\n\n```go\ntx, _ := db.Primary().Begin(ctx)\nlock, err := pglock.AcquireBlockingLock(ctx, tx, \"my_lock_id\")\nif err != nil { tx.Rollback(ctx); return err }\ndefer lock.Rollback(ctx)  // safety net\n// ... work ...\nlock.Release(ctx)         // commits\n```\n\n### Writing Raw Queries\n\nUse `pgx.NamedArgs`. Table aliases should be readable abbreviations (`memberships m`, `membership_roles mr`, `additional_privileges ap`) — never single letters.\n\n```go\nrows, _ := db.Replica().Query(ctx, `\n    SELECT id, key, encrypted_value\n    FROM secrets_v2\n    WHERE folder_id = @folderID AND user_id IS NULL\n    ORDER BY key ASC\n`, pgx.NamedArgs{\"folderID\": folderID})\nsecrets, _ := pgx.CollectRows(rows, pgx.RowToStructByName[Secret])\n```\n\n## Wiring a New Feature\n\n1. **Service** in `services/<product>/<name>/` with interface-based deps.\n\n2. **Handler** in `server/api/<product>/<name>/`:\n   - Create `openapi.yml` with endpoints\n   - Create `cfg.yaml` from template (see existing handlers)\n   - Run `go generate ./...` to create `gen.go`\n   - Create `<handler>.go` with `//go:generate` directive, `Handler` struct, `NewHandler()`\n   - Implement `ServiceInterface` methods (one file per endpoint)\n\n3. **Permission checks** (if needed): add action constants in `services/permission/<product>/actions.go`, subject struct in `subjects.go`, and a checker in `<domain>_permission.go`.\n\n4. **Wire routes** in `server/api/<product>_routes.go`:\n   - Create handler and HTTP adapter\n   - Mount routes with appropriate auth middleware\n\n5. **Register** service in `server/api/<product>_services.go`.\n\n6. Run `make test && make lint`.\n",".agents/skills/testing-secrets-ui/SKILL.md":"---\nname: testing-secrets-ui\ndescription: Test the Infisical secrets management UI end-to-end. Use when verifying secret CRUD operations, URL encoding fixes, or frontend API layer changes.\n---\n\n# Testing Secrets Management UI\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- The repo cloned locally\n\n## Setting Up Local Dev Environment\n\n1. Start the full stack:\n   ```bash\n   make up-dev\n   ```\n   This runs `docker-compose.dev.yml` which starts PostgreSQL, Redis, backend (Fastify), frontend (React/Vite), and Nginx.\n\n2. Wait for all containers to be healthy. The app runs on `http://localhost:8080`.\n\n3. **Common issue: Migration lock**\n   If the backend fails to start with \"Migration table is already locked\", unlock it:\n   ```bash\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_lock SET is_locked = 0;\"\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_startup_lock SET is_locked = 0;\"\n   docker restart infisical-dev-api\n   ```\n   Wait ~15 seconds for migrations to complete.\n\n4. Create a test account at `http://localhost:8080/signup` (local dev has no email verification).\n\n5. Create a test organization and project after signup.\n\n## Testing Secret CRUD Operations\n\n### Create Secret\n- Navigate to a project's Secret Dashboard (Project Overview page)\n- Click \"+ Add Secret\" to open the Create Secret modal\n- Fill in Environment (defaults to Development), Key, and Value\n- Click \"Create Secret\"\n- The modal uses the `createSecret` function from `frontend/src/hooks/api/secrets/mutations.tsx`\n\n### Update Secret (Inline Edit)\n- On the dashboard, click the secret's key field to edit it inline\n- Change the key or value\n- Click the save icon (floppy disk) that appears\n- This uses the `useUpdateSecretV3` mutation from `mutations.tsx`\n\n### Testing URL Encoding\nWhen testing that special characters in secret keys are handled correctly:\n- Try creating/updating secrets with `/`, `%`, `#`, `?`, `&` in the key name\n- Verify the browser network tab shows the key is URL-encoded (e.g., `/` becomes `%2F`)\n- Check backend logs: `docker logs infisical-dev-api 2>&1 | grep \"secret\"` to confirm the encoded URL\n- The error should be a \"Validation Error\" (Fastify param validation), NOT \"Route not found\"\n\n## Key Files\n\n- `frontend/src/hooks/api/secrets/mutations.tsx` — Secret create/update/delete mutations\n- `frontend/src/hooks/api/secrets/queries.tsx` — Secret query hooks\n- Backend logs: `docker logs infisical-dev-api`\n- Database: `docker exec -it infisical-dev-db psql -U infisical -d infisical`\n\n## Devin Secrets Needed\n\nNo external secrets are required for local dev testing. The local environment uses:\n- Database: PostgreSQL with user `infisical` (configured in docker-compose.dev.yml)\n- All services run locally via Docker\n","tests/CLAUDE.md":"# CLAUDE.md\n\nThe Go blackbox suite. It boots a real Infisical in Docker and calls it over HTTP only.\nIts contract with the server is a Docker image, environment variables, and the OpenAPI\nspec, so the same tests run against any implementation of that contract.\n\n## Read next\n\n| Task | Read |\n|---|---|\n| Writing or reviewing a test | [docs/writing-tests.md](docs/writing-tests.md) |\n| Using or adding a fake third party, App Connection, or mail | [docs/fakes.md](docs/fakes.md) |\n| Calling an endpoint the client does not have yet | [docs/client.md](docs/client.md) |\n| A failing, flaky, or CI-only test | [docs/debugging.md](docs/debugging.md) |\n\n## What belongs here\n\nAnything a customer or API client can observe: responses, status codes, error messages,\nmail, calls to third parties, and effects of queues or crons after the response.\n\nNot here:\n\n- Pure functions and arithmetic. Unit test them in `backend/`.\n- Anything needing direct access to Infisical's own Postgres or Redis, an internal\n  service handle, or a mocked module. Those belong in `backend/e2e-test/`.\n- Exhaustive input validation. Cover representative cases here; matrices go in unit\n  tests.\n\n## Commands\n\n```bash\nmake test-suites   # product tests; starts and stops the stack\nmake test-harness  # harness tests\nmake test-all      # both\nmake test-unit     # no containers\nmake lint          # gofmt, vet, staticcheck\n```\n\nTo iterate on one package, keep the stack up:\n\n```bash\nmake up\ngo test ./suites/secretmanager/secrets/... -count=1\nmake down\n```\n\n`-count=1` disables Go's result cache. Run `make down` after changing a container's\nconfiguration, or the old container is reused.\n\n## Layout\n\n```\nsuites/             product behaviour, grouped by product (suites/secretmanager/secrets)\nharnesstest/        harness behaviour; only for failures the harness alone can cause\nharness/            Stack, Profile, Tenant, Principal\nfixture/            platform resources: projects, App Connections\nfixture/<product>/  one package per product: secretmanager, pki, pam\nfixture/sso/        SAML and OIDC logins, for any suite whose claim needs an SSO session\nfakes/              one package per external provider\ninternal/           wait, mail, id, apierr, spec\nclients/api/        generated client; never edit by hand\n```\n\n- Platform fixtures live in `fixture`; each product's fixtures live in\n  `fixture/<product>`. A product owns its nouns: a secret in Secret Manager is not a\n  secret in Cert Manager. Within a package, add a file per resource.\n- Helpers start in the test package and move to a fixture on the second caller.\n- Imports go one way: `fixture` may import `harness` and `fakes`; `harness` never imports\n  a fixture; `fakes` import neither.\n\n## Rules\n\n- `t.Parallel()` on every test and subtest under `Shared`; never under `Isolated`.\n- Every test body is `// Setup`, `// Action`, `// Assert`.\n- Assert with `testify/require` only.\n- Every test creates its own tenant.\n- Never build an API client by hand; use a principal's `.API` or `tn.Client`.\n- Never sleep. Wait on the status the product records, then assert the outcome.\n- Test plumbing once, not per provider.\n- A rejection test also checks that nothing changed.\n- Use the actor the claim is about, not `tn.Admin` by default.\n- No package, fixture, or option for a single caller.\n\n## Before you call it done\n\n```bash\nmake lint\nmake test-all\nmake up && go test -p 4 ./suites/... ./harnesstest/... -count=3; make down\n```\n\nThe last command is the flake gate. A failure there is shared state leaking across\ntenants. Then break the new assertion, and what it depends on, and confirm both fail.\n","e2e/CLAUDE.md":"# CLAUDE.md\n\nGuidance for Claude Code sessions working in this directory. Covers both the operator-facing view (env vars, bootstrap, how to run) and the agent-facing context (architecture, gotchas, file layout).\n\n## What this directory is\n\nExternal Playwright suite targeting a deployed Infisical environment (gamma). Runs in CI between `gamma-deployment` and any prod-deploy job — defined as `gamma-playwright-e2e` in the `infrastructure` repo's `.github/workflows/infisical-deployment.yml`. A red run blocks all 11 prod/dedicated deploy jobs via `needs:`.\n\n**Not** the same as `backend/e2e-test/`, which is in-process Vitest with a bootstrapped server. That one tests the backend in isolation; this one tests the deployed product as a black box.\n\n## Scope discipline\n\nThis suite exists for **smoke-level** verification — \"does the surface still work end-to-end against real infrastructure.\" If a test is best expressed as \"given these inputs to a function, assert these outputs,\" it belongs in a unit or integration test, not here. The CI cost per test (minutes, not seconds) makes narrow coverage expensive.\n\nConcrete rule: a test here should be *useful even if every integration test passed*. It's catching things integration tests can't — config drift, deploy regressions, end-to-end auth flows that depend on real IdPs.\n\n## Architecture\n\n- **Playwright** — TypeScript, Chromium project only. Single worker, `fullyParallel: false`. The single-worker constraint is load-bearing — see \"Serial execution\" below.\n- **Two-token CI surface.** Only the two real secrets (`E2E_SCIM_TOKEN`, `E2E_IDP_ADMIN_TOKEN`) come from env vars — validated fail-fast in [`helpers/env.ts`](helpers/env.ts). Deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config row id) are constants in [`helpers/constants.ts`](helpers/constants.ts). The deliberate tradeoff: re-seeding gamma's e2e org rotates the `saml_configs.id`, which then has to be updated in code instead of in a GH Actions secret.\n- **Twingate-gated.** Gamma sits behind Twingate; the CI workflow installs the Twingate connector before tests run. Locally, you need to be on Twingate too. The Playwright Chromium inherits routes from the host network connector.\n- **Mock IdP we control.** SAML SSO points at `preview-orchestrator.infisical.workers.dev/saml-idp/*` — Hono routes on the existing `preview-environments` Worker, using `samlify` for signing under `nodejs_compat`. No SaaS account, no vendor DOM. The test calls `POST /saml-idp/identity` with a per-run NameID, sets the returned session id as a cookie on the Worker domain, then navigates to gamma's SAML start URL; the browser carries the cookie through gamma's 302 to the IdP, which mints a signed assertion for that identity. Cert is committed at [`fixtures/idp-cert.pem`](fixtures/idp-cert.pem); private key is in CF Secrets only (`SAML_IDP_PRIVATE_KEY`). Worker code lives in the `preview-environments` repo, not here — that side of the system is owned separately.\n- **Two SAML entry shapes, one spec file.** `tests/scim-saml-auth.spec.ts` covers both variants under one `describe`. SP-initiated navigates to gamma's `/api/v1/sso/redirect/saml2/organizations/:orgSlug` first; gamma 302s the browser to the Worker's `/saml-idp/sso` with an `AuthnRequest`. IdP-initiated starts at the Worker's `/saml-idp/initiate`, which mints an *unsolicited* signed `SAMLResponse` (no `InResponseTo` per SAML 2.0 §3.4.1.5) and auto-POSTs it directly to gamma's ACS at `/api/v1/sso/saml2/:samlConfigId`. The IdP-initiated test passes the gamma ACS URL through `POST /identity` (validated against the gamma SP origin allowlist), reachable via `gammaSamlAcsUrl()` in [`helpers/idp-client.ts`](helpers/idp-client.ts).\n\n## Load-bearing identity coupling\n\nThis is the one detail that, if you get it wrong, makes the test silently fail in confusing ways:\n\n```\nSCIM userName  ==  SAML NameID emitted by the IdP  ==  UserAlias.externalId\n```\n\nThe chain works because:\n- SCIM provisioning creates `UserAlias.externalId = req.body.userName` (backend `scim-router.ts:266` → `scim-service.ts:499`).\n- Org auth method is SAML, so the alias type is SAML (`scim-service.ts:428`).\n- SAML login looks up by `userAliasDAL.findOne({ externalId: profile.nameID, orgId, aliasType: SAML })` (`saml-config-service.ts:535`).\n\nWith the in-house mock IdP, the test now **owns this value end-to-end**: it generates a per-run `EXTERNAL_ID` (`e2e-${runId}@infisical-e2e.test`) and passes the same string as the SCIM `userName`, the IdP `email` (which samlify uses as the SAML `NameID`), and implicitly as `UserAlias.externalId`. There's no SaaS-side configuration to drift. If `tests/scim-saml-auth.spec.ts` and `helpers/idp-client.ts` agree on the value, the chain holds.\n\nThe current test still ends at `/signup/sso?token=…` rather than `/login/select-organization` — gamma routes any `userAlias.isEmailVerified = false` user through `SIGNUP_REQUIRED` (`auth-login-service.ts:322`), and SCIM hardcodes that field to `false` (`scim-service.ts:506`). The signup token JWT contains the verified identity fields. Patching the SCIM service to mark aliases verified at provisioning time would flip the redirect back to the original `/login/select-organization`; that's a separate backend change.\n\n## Serial execution is intentional\n\nTests run with a single worker. The old reason (single static IdP identity) is gone — the mock IdP can mint an assertion for any NameID the test asks for, so per-run unique externalIds already work.\n\nWhat still binds us to single-worker is the **shared gamma org**: there's one `saml_configs` row, one set of `email_domains` rows, one `orgAuthMethod`. Concurrent tests writing to the same org's `user_aliases`/`memberships` rows risk subtle interleavings (e.g. the same email landing on a fallback `userDAL.findOne({ username })` from two flows at once). Unblocking parallelism would mean either per-test gamma orgs (heavier seed script) or accepting that risk for tests that don't touch shared org state.\n\nIf you do enable parallel workers, also ensure the IdP `/saml-idp/identity` flow's cookie scoping survives — the Worker sets `path=/saml-idp`, so multiple concurrent tests can each set their own session cookie value on the Worker origin without collision (Playwright contexts are already isolated per test).\n\n## File layout\n\n| Path | Purpose | Touch when... |\n|---|---|---|\n| `playwright.config.ts` | Runner config — baseURL, retries, reporter | Adding a project (e.g. Firefox), changing retry policy |\n| `helpers/env.ts` | Fail-fast env loader for the two bearer-token secrets | Adding a new *secret* (also update `.env.example` and the CI workflow) — non-secret config goes in `constants.ts` instead |\n| `helpers/constants.ts` | Hardcoded deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config id) | Gamma's e2e org is re-bootstrapped and `saml_configs.id` rotates — update `SAML_CONFIG_ID`; the seed script logs the new value on the `[seed] E2E_SAML_CONFIG_ID = <uuid>` line |\n| `helpers/scim.ts` | SCIM CRUD wrappers, reusable across tests | Any new SCIM operation a test needs (e.g. Groups) |\n| `helpers/idp-client.ts` | Talks to the mock IdP — stashes a NameID (+ optional ACS URL for IdP-initiated), sets the Worker-domain session cookie, and starts either SP- or IdP-initiated flows | Adding a new SAML-flavored flow, or if the Worker IdP's API/cookie scheme changes |\n| `tests/*.spec.ts` | One test per flow | Adding a new flow (see below) |\n| `fixtures/idp-cert.pem` | Committed public cert for the mock IdP; gamma's SAML config row holds this same cert (KMS-encrypted) | Rotating the IdP keypair (see [`fixtures/README.md`](fixtures/README.md)) |\n| `tsconfig.json` | TS config — no `baseUrl`/`paths` on purpose, relative imports only | Avoid unless you have a real reason |\n\n## Adding a new test\n\n1. **One *flow* per file**, not one test per file. A flow is a distinct end-to-end path being verified (e.g. \"SCIM-provisioned user logs in via SAML\", \"SAML response rejection\", \"SCIM deactivation revokes SAML login\"). Variants of the *same* flow that differ only in entry shape or input (SP-initiated vs IdP-initiated SAML; each strictness gate within a rejection suite) belong in the same file under one `test.describe`, as multiple `test()` blocks. Distinct flows get their own file.\n2. Name the file after the flow (`scim-saml-auth.spec.ts`, `saml-rejection.spec.ts`, `scim-deactivation.spec.ts`). Per-variant naming goes inside the file as test titles.\n3. Import from `../helpers/constants`, `../helpers/env`, `../helpers/scim`, `../helpers/idp-client`. Add a new helper module only if a fundamentally new flow needs shared setup (e.g. an OIDC helper if/when that's covered).\n4. **Per-variant state goes in `beforeEach` / `afterEach`**, not `beforeAll` / `afterAll`. Each variant test should own its own SCIM user (fresh `externalId`) so a mid-test crash on one variant doesn't poison the next. See `saml-rejection.spec.ts` for the pattern. Use `beforeAll`/`afterAll` only when there's genuinely shared, idempotent state across the whole file.\n5. **Always clean up.** `beforeEach` defensively deletes any state the test creates; `afterEach` deletes what the test produced. Tests crash mid-flow sometimes; the e2e org accumulates rows otherwise. Note: the SAML signup branch also creates a fresh user row on each run — currently leaks; revisit when SCIM's `isEmailVerified` default is patched.\n6. **One assertion-block per test should be enough** to claim the variant works. Factor a shared `expect…()` helper inside the file when multiple tests assert the same outcome shape (e.g. `expectSignupSsoRedirectFor(page, email)` in `scim-saml-auth.spec.ts`) — that's preferable to copying ~10 lines of expect calls across tests. Don't expand a smoke test into a unit-test-style assertion sprawl — push fine-grained detail into integration tests.\n7. **Don't share helpers across spec files.** Helpers live in `helpers/*` or as file-local consts/functions at the top of the spec. Cross-spec imports create CI debugging cliffs — avoid.\n8. Run locally first (`npm test`) with `.env` populated. CI will replicate via GH Actions secrets.\n\n## What you can break by accident\n\nThese external dependencies have to stay in sync — if you change the backend or the Worker, the test breaks silently. Worth grepping for after any change in these areas:\n\n- **SCIM endpoint URL** (`backend/src/ee/routes/v1/scim-router.ts`, mounted at `/api/v1/scim`). `helpers/scim.ts` hardcodes `/api/v1/scim/Users` and `/api/v1/scim/Users/:id`. If the router prefix moves, update here.\n- **SAML start/ACS URLs** (`backend/src/ee/routes/v1/saml-router.ts`). `helpers/idp-client.ts` hardcodes `/api/v1/sso/redirect/saml2/organizations/:orgSlug` (SP-initiated) and `/api/v1/sso/saml2/:samlConfigId` (IdP-initiated ACS, built from `SAML_CONFIG_ID` in `helpers/constants.ts`). If either path moves, both consumer sites need to follow.\n- **Post-login redirect** (`saml-config-service.ts` → `auth-login-service.ts:322`). `tests/scim-saml-auth.spec.ts` asserts `/signup/sso?token=…`; if SCIM gets patched to mark aliases verified, the redirect flips to `/login/select-organization` and the assertion needs to follow.\n- **NameID-to-externalId mapping** (`saml-config-service.ts:535`). Currently `profile.nameID`. If the matching attribute changes, the identity-coupling section above becomes stale.\n- **`scimEnabled` / `orgAuthMethod` semantics** (`scim-service.ts:413, 419`). If new gating gets added (e.g. additional flags), the seed script in `backend/scripts/seed-gamma-fixtures.ts` needs to set them.\n- **Verified email domain check** (`email-domain-fns.ts:11`). SCIM POST and SAML login both require the email domain to be on the org's `email_domains` allowlist with `status='verified'`. The seed script writes one for `infisical-e2e.test`; if the test ever uses a different domain, add a row.\n- **Mock IdP cert ↔ gamma SAML config cert** must match. Seed script reads the committed cert from `e2e/fixtures/idp-cert.pem` and KMS-encrypts it into the `saml_configs` row; the Worker signs with the matching private key from `SAML_IDP_PRIVATE_KEY` (CF Secret). Rotating one requires rotating the other — see `fixtures/README.md`.\n- **passport-saml strictness** (`saml-router.ts:51-117`). Under `authProvider = keycloak-saml` (what the seed script writes), no overrides apply — passport-saml requires both Response and Assertion signed, attribute `email`/`firstName`/`lastName` present, and audience matching `appCfg.SITE_URL`. `validateInResponseTo` defaults to `\"never\"` in `@node-saml/node-saml` 5.x, which is why the IdP-initiated path works at all; flipping it to `\"ifPresent\"` or `\"always\"` would break IdP-initiated first. The Worker's unsolicited template omits `InResponseTo` entirely (per SAML 2.0 §3.4.1.5) so the test stays valid if that default is ever tightened. Changing the seed's `authProvider` value would change the strictness profile.\n\n## Common pitfalls\n\n- **Diagnostics about `@playwright/test`** before `npm install` runs — expected. Resolve by `cd e2e && npm install`.\n- **The seed script's bootstrap is one-pass now.** Direct DB writes don't trigger `updateSamlCfg`'s `scimEnabled=false` side effect, so a single run leaves a usable state. (The old SSOJet-era two-pass requirement is gone.) Re-run is still safe; it's idempotent.\n- **CF Access bypass for `/saml-idp/*`** must be in place on the `preview-orchestrator` Worker, otherwise every request to the mock IdP 302s to the CF Access login page. The bypass is safe because the IdP routes have their own auth (`Authorization: Bearer SAML_IDP_ADMIN_TOKEN` on `/identity`, cookie + KV lookup on `/sso` and `/initiate`, intentionally public on `/metadata.xml`).\n- **Worker changes ship via `wrangler deploy`**, not the gamma deploy. After editing Worker code (`/saml-idp/*` routes, IdP templates), deploy from the `preview-environments` repo before running the test or it'll hit the previous version. `tsc` passing here doesn't prove the Worker is up to date.\n- **SCIM POST + SAML signup leak a user row per test run** because the SAML signup branch creates a fresh `users` row even though SCIM already created one. SCIM `DELETE /Users/:id` removes the membership but not the orphan user. Tractable but accumulates; revisit if it becomes noisy.\n- **Don't add a `.npmrc` here for the 7-day minimum-release-age rule** — `e2e/` is a test harness, not a runtime artifact. The supply-chain concern that motivates the rule in `backend/`/`frontend/` doesn't apply.\n- **CI uses `npm ci`** against the committed `package-lock.json` for determinism. If you change deps, regenerate the lockfile (`npm install`) and commit it in the same change, otherwise `npm ci` fails the prod gate.\n- **Don't widen what the seed script creates** unless you've thought through the safety. It already refuses prod (host/dbname regex + `INFISICAL_ENV=prod` + interactive confirmation). Adding more powerful operations means re-evaluating the guard surface.\n\n## Where context lives\n\n- CI workflow: `infrastructure` repo, `.github/workflows/infisical-deployment.yml` (the `gamma-playwright-e2e` job)\n- Seed script: [`../backend/scripts/seed-gamma-fixtures.ts`](../backend/scripts/seed-gamma-fixtures.ts)\n- Backend SCIM service: `../backend/src/ee/services/scim/`\n- Backend SAML service: `../backend/src/ee/services/saml-config/`\n- Mock IdP Worker code: `preview-environments` repo, `src/saml-idp/` (separate repo — owned independently)\n- IdP keypair provenance + rotation: [`fixtures/README.md`](fixtures/README.md)\n- Root architectural notes: [`../CLAUDE.md`](../CLAUDE.md), [`../backend/CLAUDE.md`](../backend/CLAUDE.md)\n","frontend/CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **frontend** package of the Infisical monorepo — a React 19 SPA built with Vite 8, TanStack Router, React Query, and Tailwind CSS v4.\n\n## Essential Commands\n\nAll commands run from the `frontend/` directory:\n\n- `npm run dev` — start dev server on port 3000\n- `npm run build` — TypeScript check + Vite production build\n- `npm run lint:fix` — ESLint autofix (double quotes, simple-import-sort with 7 groups)\n- `npm run type:check` — TypeScript type check only (`tsconfig.app.json`)\n- `make reviewable-ui` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n- `npm run storybook` — Storybook on port 6006\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## Architecture Overview\n\n### Routing (TanStack Router v1)\n\nVirtual file routes defined in `src/routes.ts` using a functional DSL (`route()`, `index()`, `layout()`, `middleware()`). The generated route tree is in `src/routeTree.gen.ts` (do not edit manually — regenerated on dev server start or when `src/routes.ts` changes).\n\nEach page directory has a `route.tsx` that defines its route:\n```tsx\ncreateFileRoute(path)({ component, validateSearch: zodValidator(schema), beforeLoad })\n```\n\nRoute middleware chain: root → `authenticate` → `inject-org-details` → org-layout → product-specific layouts (secret-manager-layout, cert-manager-layout, kms-layout, etc.).\n\nMiddleware pages in `src/pages/middlewares/`: `authenticate.tsx` (auth guard + redirect), `inject-org-details.tsx` (org context), `restrict-login-signup.tsx` (prevents auth pages when logged in).\n\n### Pages / Views / Components Hierarchy\n\n- **`src/pages/`** — Route-level components organized by product feature (secret-manager, cert-manager, kms, pam, secret-scanning, organization, project, admin, auth). Each has `route.tsx` + page component + local `components/`.\n- **`src/views/`** — Reusable page-level UI composed into multiple pages. Pages import views with configuration props.\n- **`src/components/v3/`** — Latest shared UI component library (preferred). Contains `generic/` (Accordion, Alert, Button, Dialog, Select, Table, etc.) and `platform/` (domain-specific components such as `PageHeader`, scope icons, `ProjectPermissionSubjects`, and `SecretManagerResources`). **Always use v3 components for new code.** Only use v2 components when a v3 equivalent does not exist. Policy group tiles look up `getProjectPermissionSubjectPresentation`. Secret Manager overview resources (name/slug/icon/color) come from `SecretManagerResources`, which points at that subject catalog.\n- **`src/components/v2/`** — Legacy shared UI components built on Radix UI primitives + Tailwind. Uses `cva` (class-variance-authority) for variants and `tailwind-merge` for class conflict resolution. Being superseded by v3 — do not use for new features if a v3 alternative exists.\n\n### API Layer (React Query + Axios)\n\nEach API domain in `src/hooks/api/` (100+ domain folders) follows this structure:\n- **`queries.tsx`** — Query key factory pattern: keys return `[{ params }, \"domain-label\"]`. Export named query hooks (`useGetSecrets`, etc.) and raw fetch functions.\n- **`mutations.tsx`** — `useMutation` hooks that invalidate relevant query keys on success via `queryClient.invalidateQueries()`.\n- **`types.tsx`** — Request/response DTOs.\n\nHTTP client in `src/config/request.ts`: Axios instance with automatic token injection and 401/403 interceptors.\n\n#### React Query Global Defaults (`src/hooks/api/reactQuery.tsx`)\n\nThe `QueryClient` sets these global defaults for all queries:\n- **`staleTime: 60_000`** (60 seconds) — data fetched within the last 60s is considered fresh and won't be refetched on component mount/remount. This prevents redundant API calls during normal page navigation. Queries that need real-time data (e.g., identity auth configs, dynamic secret leases) override this with `staleTime: 0`.\n- **`refetchOnWindowFocus: false`** — queries do not refetch when the browser tab regains focus.\n- **`retry: 1`** — failed queries retry once.\n\nMutation failures are reported globally by `MutationCache.onError` using the server error message. Do not add another error `createNotification` in a mutation `catch` or `onError`, because it produces duplicate toasts. Local error handling may still restore optimistic state, keep a dialog open, or perform other control flow without displaying a second notification.\n\nWhen adding new queries, consider whether the default 60s staleTime is appropriate:\n- For data that changes only on explicit user action (secrets, folders, org metadata): the 60s default is fine or could be longer.\n- For data that must always be fresh (auth configs, lease TTLs): override with `staleTime: 0, gcTime: 0`.\n- For rarely-changing data (server config, user profile): use `staleTime: Infinity` as the context providers do.\n\nOrganization switching resets the query cache centrally in `pages/middlewares/inject-org-details.tsx`, after authentication succeeds and before tenant data loads. `hooks/api/organization/cache.ts` tracks the active organization per `QueryClient` and removes all queries except the exact auth-token, user-profile, server-config, and session-announcement keys. Removing queries also cancels pending query results, including queries whose keys omit the organization. Same-organization navigation keeps the cache, and the organization layout remounts on organization changes to reset local UI state. Keep tenant cache cleanup at this boundary rather than adding feature-specific removal lists to individual organization selectors. Logout paths must use `clearSession` to remove user-specific cache entries while preserving public server configuration for the login page.\n\nOrganization switchers use `helpers/organizationSwitch.ts` to retain safe sections and tabs while dropping resource IDs, action parameters, and legacy suborganization search state. Pass the current location through `navigateUserToOrg` only for organization switches; initial login keeps its Projects landing page. Explicit destinations take precedence, and MFA route handoffs retain the requested destination through `redirect_to`.\n\n### Product Analytics\n\nUse `@app/lib/analytics` for new frontend product analytics. Define the event and its typed properties in the shared catalog, then capture it through the method matching its scope so required grouping is applied automatically. Do not call the legacy `Telemetry` wrapper directly from new code or define event names as local strings.\n\nRead [`../ANALYTICS.md`](../ANALYTICS.md) before adding or changing an event. It defines naming, ownership between frontend and backend producers, property cardinality and privacy rules, and lifecycle semantics. Frontend events describe UI exposure and intent; decisive product outcomes belong to backend telemetry so web activity is not double-counted against CLI, machine identity, or other clients.\n\n#### PostHog feature flags and A/B tests\n\n- Put each experiment in `src/components/analytics/experiments/<experiment>/`, with its flag key, typed variants, assignment hook, and focused Vitest coverage kept together. Shared lifecycle code belongs in `src/lib/analytics/experiments/`.\n- Resolve PostHog flags through `resolveFeatureFlagVariant`. It performs a fresh lookup after flags load and owns timeout, error fallback, late-callback suppression, unsubscribe, and unmount cancellation. Set exposure-event behavior deliberately, and persist only values that the experiment resolver recognizes as real assignments.\n- Define the eligible population before initializing PostHog. Excluded flows must not request the flag or emit the experiment conversion. An eligible user who completes after assignment fails can be attributed as `unassigned`; an ineligible user must not be counted.\n- Keep an assignment sticky for the full conversion path, including redirects or region changes. Register dedicated conversion events and typed properties in `@app/lib/analytics`; do not reuse a broader event with different semantics or default unenrolled users into the control cohort.\n- Allow forced variants only in local development or pull-request previews. Production cloud domains must use PostHog assignment, and self-hosted instances must not initialize cloud experiments.\n- Create the PostHog flag and experiment as inactive drafts. Validate variant keys, exposure, conversion events, and metrics against a preview deployment before anyone launches the experiment separately from the code rollout.\n\n### State Management\n\n- **Server state**: TanStack React Query (query key factories in each API domain)\n- **Global app state**: React Context providers in `src/context/` — User, Organization, Project, OrgPermission, ProjectPermission, ServerConfig, Subscription\n- **Local component state**: Zustand stores\n\n### Permissions\n\nCASL-based (`@casl/ability`). Contexts: `OrgPermissionContext` and `ProjectPermissionContext` in `src/context/`. Access via `useOrgPermission()` / `useProjectPermission()` hooks. HOC gates: `src/hoc/withPermission/` and `withProjectPermission/`.\n\n### Styling\n\nTailwind CSS v4 with PostCSS. Dark theme configured via CSS custom properties in `src/index.css` (`@theme` directive). Custom breakpoint `dashboard: 1100px`. Typography roles: IBM Plex Sans is the default product UI face (`font-sans`); Alliance No. 2 Regular is for headings (`font-alliance`); JetBrains Mono is the functional mono (`font-mono`) for code, values, identifiers, and technical microcopy. See the root `DESIGN.md` for typography roles. Colors use semantic roles from `src/index.css`, including surfaces, content, structure, status, scope, and product/resource accents.\n\n### Layouts\n\n9 layout components in `src/layouts/` — `AdminLayout`, `OrganizationLayout`, `SecretManagerLayout`, `PkiManagerLayout`, `KmsLayout`, `PamLayout`, etc. Layouts handle sidebar navigation and page chrome for their product area.\n\n### Org-scoped products\n\nPAM and Agent Vault live at `/organizations/$orgId/<slug>` over a single implicit project, so their URLs carry no `$projectId`. Their layout's `beforeLoad` publishes `implicitProjectId` and `implicitProductType` into route context, and `useImplicitProjectId()` / `useImplicitProduct()` read them back off the matched routes. **Anything that decides whether a `useProject()` caller renders must use `useImplicitProduct()`, never the pathname.** The router changes location before it swaps matches, so a pathname gate mounts a frame early on the way in and unmounts a frame late on the way out, leaving the component asking for a project the router no longer resolves — which throws into `ErrorPage`. Reading the matches keeps these products in step with `useParams`, which is match-derived and is what every other product's gate already uses. The gates that matter today are `OrgSidebar`'s `isInsideProject`, the `ProjectSelect` wrapper, and `OrganizationCommandMenu` in `RootCommandMenu.tsx`, which had exactly this bug when it arrived from main with a pathname check; `getOrgScopedProductFromPath` remains correct for the cosmetic uses behind no `useProject` (navbar scope, `useScopeVariant`, `TypeSelect`).\n\nA new org-scoped product therefore needs: a `<product>ProjectId` on the `Organization` type, an entry in `ORG_SCOPED_PRODUCT_TYPES` and `getOrgScopedProductFromPath` (`helpers/project.ts`), and a layout `beforeLoad` that bootstraps the id, patches it into the cached org, and returns both `implicitProjectId` and `implicitProductType` (see `pages/agent-vault/layout.tsx`). Use `isOrgScopedProduct(type)` rather than adding another pathname check; the sidebar, navbar, project switcher and nav links all key off it.\n\nCertificate Manager is org-scoped too (`/organizations/$orgId/cert-manager/...`), but legacy orgs can hold several instances, so its layout resolves the project from instance state instead of an org field, through `resolveCertManagerProjectId` (`helpers/certManagerActiveProject.ts`): the `infisical-cm-active-project-<orgId>` cookie while the user is a member of that instance, else the active instance. The product switcher and the 403 page use the same helper. The layout writes the resolved id back to the cookie, because `inject-cert-manager-project-id.ts` resolves requests that name no project from it, and reloads the page when the resolved instance changes within one page load, since several cert-manager queries are not keyed by project. Consequences: anything that sends someone into a *specific* instance (the instance picker, a project-scoped identity row, a gateway's discovery row) sets that cookie first, since the URL can't say which; old `/projects/cert-manager/$projectId/...` URLs (emails and alerts sent before the move, bookmarks; the backend now links to `/cert-manager/...`, except legacy PKI Alerts V2 and the legacy-deprecation email) go through `redirects/cert-manager-project-url-redirect.tsx`, which pins the named instance and strips the id. In multi-instance orgs each tab also tags its Certificate Manager API requests with the instance it shows (`setTabCertManagerProjectId`, read by the `apiRequest` interceptor), because the cookie is shared across tabs; single-instance orgs send no project id. Shared views that pick project vs org scope from the URL use `useRouteProjectId()`, which also returns Certificate Manager's implicit project.\n\n### Single-project products\n\nSecret Scanning has one active project per org (see the root `CLAUDE.md`). Members are sent straight into it, and UI for orgs with older projects is gated on `isMultiInstance` from `useGetSecretScanningInstanceState`.\n\n## Conventions\n\n- ESLint flat config (ESLint 9+) with airbnb-typescript + prettier. Double quotes enforced.\n- Import ordering via `simple-import-sort`: node builtins → react/external packages → `@app/` → internal → relative → styles.\n- Forms use `react-hook-form` with `@hookform/resolvers` (Zod schemas).\n- Search params validated with `zodValidator()` from `@tanstack/zod-adapter`.\n- Toasts: call `createNotification({ title?, text, type, callToAction?, copyActions? })` from `@app/components/notifications`. Backed by **sonner** (the v3 `Toaster` in `components/v3/generic/Toast`), mounted once via `NotificationContainer` in `pages/root.tsx`. `react-toastify` has been removed, so do not reintroduce it.\n- Unsaved changes: use `useDiscardChangesGuard` with `DiscardChangesAlertDialog` when dismissing an overlay editor would remove unsaved form changes. Wire dismiss/close to `requestDiscard`. Do not use `window.confirm`. Do not use `useNavigationBlocker` — that hook is secrets batch-mode only.\n","backend/CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **backend** package of the Infisical monorepo — a Fastify 4 API server with TypeScript, PostgreSQL via Knex, and BullMQ queues.\n\nThe backend code quality guide is imported below, so it is always in context for work in this package. **Check every change against it, whatever the change is, before calling the task done.** This file covers *where* code goes and *which* pattern to follow; the quality guide covers the minimum any implementation has to handle. It is a deliberately non-exhaustive floor, so the topics it happens to cover are not a filter for whether it applies.\n\n@CODE_QUALITY.md\n\n## Essential Commands\n\nAll commands run from the `backend/` directory:\n\n- `npm run dev` — start dev server with tsx watch + pino-pretty logging (`dev:docker` is the same with the debugger on 9229)\n- `npm run build` — production build via tsdown (`tsdown.config.ts`) with sourcemaps\n- `npm run lint:fix` — ESLint autofix\n- `npm run type:check` — TypeScript check (uses 8GB heap)\n- `make reviewable-api` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n\nThe backend runs on Node 26. TypeScript files run directly through tsx (dev server, scripts, the\n`*-dev` knex commands, and the e2e environment's migration loading); there is no ts-node.\n\n### Build (tsdown)\n\n`tsdown` compiles every module to its own `dist/**/*.mjs` (unbundled ESM) and rewrites `@app/*` to\nrelative paths itself. Invariants that a config change can silently break:\n\n- **The output extension stays `.mjs`.** knex records each migration's file name, extension included, in\n  `infisical_migrations`, so production rows are `*.mjs`. A different extension makes every applied\n  migration look unknown and the boot check refuses to start.\n- **`main` stays the first entry, and entry paths stay absolute.** Rolldown orders each file's imports by\n  execution order, walking entries in order; a glob that sorts another entry ahead of `main` hoists its\n  imports above the telemetry instrumentation. Object entries ignore `root`, so relative paths nest every\n  other module under `dist/src/`.\n- **Rolldown reorders imports, even within one file.** Never rely on import order for a global side effect\n  that another package needs; import it explicitly where it is needed (see `reflect-metadata` in\n  `lib/crypto/pqc/pqc-algorithm.ts`).\n- **`deps.onlyBundle: []` fails the build if a `node_modules` package would be inlined.** That means\n  `src/` imports a package that `package.json` does not declare; declare it instead of loosening the guard.\n\n### Testing\n\nRun both suites from the repo root, which is the same entry point CI uses:\n\n- `make test-api-unit` — unit tests matching `./src/**/*.test.ts`\n- `make test-api-e2e` — e2e tests matching `./e2e-test/**/*.spec.ts`\n- `make test-api-e2e SPEC=<pattern>` — narrow to one spec, e.g. `SPEC=secret-sync`\n- `make up-rotation-databases` — start the databases the secret rotation specs need, required\n  only for a full run (`docker-compose.e2e-dbs.yml`, and the Oracle image is large)\n- `make down-test-suite-containers` — stop everything when finished\n\nThe secret rotation specs reach their databases by compose service name, so\n`docker-compose.e2e-dbs.yml` pins the same compose project as the test stack and shares its\nnetwork. Without `make up-rotation-databases`, those specs fail on connection; the rest pass.\n\nBoth run inside the FIPS image (`Dockerfile.dev.fips`), which carries the native dependencies\nthe suites need (SoftHSM2, the Oracle client, the FIPS OpenSSL build) and pins the Node\nversion, so the host's does not matter. `docker-compose.test.yml` declares the image,\nenvironment, mounts and services; `src`, `e2e-test` and both vitest configs are mounted, so\nediting a test needs no rebuild but changing `package.json` or `tsconfig.json` does.\n\n**`npm run test:e2e` directly is possible but destructive if misconfigured.** The Vitest\nenvironment runs `DROP SCHEMA public CASCADE` on whatever database `.env.test` points at,\nbefore every run. Copy `.env.test.example`, which targets the throwaway stack, and start it\nwith `make up-test-suite-containers`. Never point it at the dev database.\n\nUnit tests go next to source as `*.test.ts` and test pure functions with Vitest globals (`describe`, `test`, `expect`).\n\nE2E tests live in `e2e-test/routes/`. The custom Vitest environment (`e2e-test/vitest-environment-knex.ts`) bootstraps a full server with DB, Redis, and encryption. Tests use injected globals: `testServer` (Fastify instance), `jwtAuthToken` (pre-authenticated JWT). Use `testServer.inject()` for HTTP assertions. Test helpers in `e2e-test/testUtils/` provide CRUD wrappers for secrets, folders, and secret imports. See `e2e-test/routes/v1/org.spec.ts` for a representative e2e test.\n\n#### Writing e2e tests with `testUtils`\n\n**Build test state with the helpers in `e2e-test/testUtils/`, not by hand.** Before writing a\n`testServer.inject` call or a `testDb` insert to set something up, check `testUtils/` for a helper\nthat already does it. If none exists, add one there rather than a local helper in the spec: two\nspecs had each written their own `createTargetUser` before `testUtils/users.ts` existed, and every\ncopy drifts on its own.\n\n- **Isolate each test.** `createIsolatedOrgAndProject` (`fixtures.ts`) gives a test its own org and\n  project, a token scoped to it, and a `cleanup` that deletes the org. Prefer it over the shared\n  `seedData1` org and project, whose state leaks between tests and files.\n- **Don't lean on the seed user as a participant.** When a test needs someone other than the caller\n  (a recipient, a member, a target), create them with `createUser` and `addUserMembership`\n  (`users.ts`). Those write rows directly because signup needs SRP, so the user can be named but\n  cannot log in.\n- **Go through the API for everything else**, with the domain helpers (`secrets.ts`, `folders.ts`,\n  `alerts.ts`, `reminders.ts`, `identities.ts`, ...). Write a helper with `request()` (`request.ts`)\n  so a call is sent when made and a caller can `.expect()` a non-200 or take the `.raw()` response.\n- **Assert on outcomes a user would see**: an API response, an email in `testSmtp`, a message at a\n  fake destination. Read `testDb` only when the API cannot show the thing under test (eg an alert\n  orphaned by a cascade), and say why in a comment.\n- **Wait with `pollUntil`** (`poll.ts`) for anything asynchronous, never a fixed sleep.\n- **Run background jobs on demand** through a pass-through wrapper in `e2e-test/fakes/` (eg\n  `reminder-queue.ts` and `event-outbox-queue.ts`, driven by `runDailyReminders` in `reminders.ts`).\n\n#### Faking a third-party provider\n\n**Never add test-only code to `src/`** — no test-mode enum members, no lookup map entries, no\n`isTestMode` branches. Replace the module instead, from `test.alias` in\n`vitest.e2e.config.mts`, with a double under `e2e-test/fakes/`. Production code stays unaware\na fake exists. `e2e-test/fakes/aws-parameter-store-sync-fns.ts` and its connection counterpart\nare the worked examples, and the pre-existing `./license-fns` alias is the precedent.\n\nFour things decide whether this works:\n\n- **Alias the narrowest specifier.** Entries match the import string, so aliasing one a single\n  file imports (a barrel's `./x-fns` re-export) swaps that seam and leaves the constants,\n  schemas, types, router and lookup maps real.\n- **`test.alias` must stay an array.** Vite's `mergeAlias` concatenates arrays with\n  `test.alias` first, but merges two objects, where the generic `@app` prefix matches before a\n  specific `@app/...` entry and the fake silently stops applying with no error.\n- **Re-export whatever you do not replace.** The alias swaps the whole module, so an export you\n  omit stops existing for every importer of it.\n- **Assert the fake still matches.** Nothing otherwise checks it against the module it\n  replaces. Export an assignment typed as `Pick<typeof RealModule, …>` so a signature change\n  fails type-checking rather than leaving the fake quietly wrong.\n\nA fake must reproduce the contract under test, not merely record its input. The Parameter Store\nfake reimplements the real reconciliation rules (skip empty writes, delete absent keys unless\ndeletion is disabled, respect the key schema), because those rules are the behavior the specs\nexist to pin.\n\n#### FIPS test image and the prebuilt toolchain\n\nCI (`.github/workflows/run-backend-tests.yml`) runs the e2e suite inside a FIPS image built\nfrom `Dockerfile.dev.fips`. The expensive, rarely-changing parts of that image (SoftHSM2,\nOracle Instant Client, the FIPS OpenSSL 3.1.2 build and the PQC OpenSSL 3.5.6 build) live in\n**`Dockerfile.fips-toolchain`**, which is published to\n`ghcr.io/infisical/backend-fips-toolchain` for amd64 and arm64 by\n`.github/workflows/publish-fips-toolchain.yml` on pushes to `main`.\n\n`Dockerfile.dev.fips` consumes it via the `TOOLCHAIN_IMAGE` build arg, so CI and local dev\npull the toolchain instead of spending ~15min compiling it. Consequences worth knowing:\n\n- **Editing `Dockerfile.fips-toolchain` is expensive.** CI pins the image by the content hash\n  of that file, so a PR touching it has no published tag and rebuilds from source (the old,\n  slow path). It publishes once the PR lands on `main`. Keep app-level changes in\n  `Dockerfile.dev.fips`.\n- **Don't add `cache-to: type=gha` back to the image build.** GHA cache is PR-scoped (so it\n  never hits on a PR's first run) and the repo's 10GB cache budget is already full; writing\n  image blobs there evicts the `node_modules` caches of every workflow.\n- **The published image is an internal CI build cache, not a supported artifact.** It is\n  public only so CI and local dev can pull it; it carries no support or compatibility\n  guarantee. It is *not* the FIPS product image; that one is `infisical/infisical-fips`,\n  built from `Dockerfile.fips.standalone-infisical`. OCI labels on the image say the same.\n- **Keep every third-party fetch pinned and checksummed.** SoftHSM2 is pinned to a commit\n  SHA (tags are mutable); both OpenSSL tarballs and the Oracle Instant Client zip are\n  SHA256-verified; the Infisical CLI apt package is version-pinned. Oracle Instant Client\n  is redistributed under the Oracle Free Distribution, Hosting, and Use Terms, which\n  require the license to travel with it, so `/opt/oracle/instantclient_23_26/BASIC_LICENSE`\n  must not be removed.\n\n##### Building the dev stack without GHCR\n\n`docker compose -f docker-compose.dev.yml build backend` pulls the toolchain from GHCR by\ndefault. If GHCR is down, the package is private, you're offline, or you're iterating on the\ntoolchain itself, build it locally once and point the compose build at it:\n\n```bash\n# 1. Build the toolchain from source (~15min, or ~2min if your layer cache is warm)\ndocker build -f backend/Dockerfile.fips-toolchain -t fips-toolchain:local backend\n\n# 2. Prefer .env, so it survives every compose invocation (see the warning below)\necho 'TOOLCHAIN_IMAGE=fips-toolchain:local' >> .env\n\n# ...or pass it per-invocation. Both forms work but are easy to forget:\nTOOLCHAIN_IMAGE=fips-toolchain:local docker compose -f docker-compose.dev.yml build backend\ndocker compose -f docker-compose.dev.yml build --build-arg TOOLCHAIN_IMAGE=fips-toolchain:local backend\n```\n\n**Use `.env` rather than a one-off env var.** The documented start command in\n`docs/contributing/platform/developing.mdx` is `up --build`, which rebuilds the backend and\nre-resolves the `TOOLCHAIN_IMAGE` default, so a per-invocation override silently stops\napplying and you're back to a failing GHCR pull. Compose interpolates the project `.env` for\nevery invocation, so putting it there sticks.\n\nNotes:\n\n- Step 1 is only needed once per change to `Dockerfile.fips-toolchain`. The local tag name is\n  arbitrary; it just has to match what you point `TOOLCHAIN_IMAGE` at.\n- An already-built `backend` image keeps working if GHCR is unreachable, as long as you don't\n  pass `--build`. This only bites on a rebuild.\n- This is not an offline story. The toolchain build itself still fetches Debian packages, both\n  OpenSSL tarballs, SoftHSM sources, and the Oracle client. With no network at all, neither\n  path works and you need a previously built image.\n\n### Database\n\n- `npm run migration:new` — create new migration (interactive prompt)\n- `npm run migration:latest-dev` — run pending migrations (dev, uses `src/db/knexfile.ts`)\n- `npm run migration:rollback-dev` — rollback last migration batch (dev)\n- `npm run generate:schema` — regenerate Zod types from DB into `src/db/schemas/` (always run after migration changes)\n- `npm run seed-dev` — run database seeds\n\n- **Every foreign-key column must be covered by an index** (its own, or as the leftmost column of a composite index). Postgres does **not** auto-index FK columns. This holds regardless of cascade behavior: an unindexed FK forces a seq-scan of the child table on every parent `DELETE`/`UPDATE` (per-row RI trigger), and the FK column is almost always also a join/filter key. Watch helper-generated FKs — `createJunctionTable` (`src/db/utils.ts`) creates CASCADE FK columns with **no index** on either side.\n\n### Scaffolding\n\n- `npm run generate:component` — interactive generator that can create a service module (DAL + service + types), a standalone DAL, or a router\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## ESLint & Import Ordering\n\nConfig in `.eslintrc.js`. Uses `simple-import-sort` with this group order:\n1. Side-effect imports\n2. `node:` builtins\n3. Third-party packages\n4. `@app/` imports\n5. `@lib/` imports\n6. `@server/` imports\n7. Relative imports\n\n## Architecture\n\n### Service Factory + Manual DI\n\nNo IoC container. Every service is a factory function that receives explicit dependencies as a typed object and returns an object of methods. Dependencies are narrowed with TypeScript `Pick` to define minimal interface contracts.\n\nThe entire dependency graph is manually wired in `src/server/routes/index.ts`:\n- **~line 480-646**: DAL instantiation — each DAL factory receives the `db` client\n- **~line 649-2800**: Service instantiation — each factory receives its DALs + other services\n- **~line 2831-2972**: `server.decorate(\"services\", {...})` exposes ~100+ services as `server.services.*`\n- **~line 3082-3098**: Route registration — EE routes registered before community routes per API version\n\nSee `src/services/secret/secret-service.ts:104-162` for a representative factory with ~20 dependencies. See `src/services/user/user-service.ts` for a simpler example (~8 dependencies).\n\n### DAL Layer (Data Access)\n\nEach service has a `*-dal.ts` that wraps `ormify()` (defined in `src/lib/knex/index.ts:155-326`). `ormify()` provides typed CRUD methods: `findById`, `find`, `findOne`, `create`, `insertMany`, `batchInsert`, `upsert`, `updateById`, `update`, `deleteById`, `delete`, `countDocuments`, `transaction`.\n\nDALs extend these with custom queries — typically complex joins using Knex query builder and `sqlNestRelationships()` for nested entity mapping. All methods accept an optional `tx` parameter for transaction threading.\n\n**Read replica pattern**: Read methods use `db.replicaNode()` (e.g., `(tx || db.replicaNode())(tableName)`), while writes always hit the primary (`(tx || db)(tableName)`). See any `ormify()` method in `src/lib/knex/index.ts` for the pattern.\n\n`updateById` and `update` support atomic `$incr` and `$decr` operators alongside regular field updates.\n\n**A dropped column does not fail type checking if the insert is built in a `.map()`.** TypeScript's\nexcess-property check only fires on a fresh object literal at the assignment site, so a stale field\nsurvives when the literal is returned from a callback:\n\n```ts\ninsertMany(names.map((name) => ({ name, role: \"member\", orgId })));  // compiles, then 500s at runtime\ninsertMany([{ name, role: \"member\", orgId }]);                       // TS2353\n```\n\nSee `src/services/secret/secret-dal.ts` for a DAL that overrides the base `update` to auto-increment version and adds complex join queries.\n\n### Service Module Structure\n\nServices live in `src/services/` (100+ modules). Each typically contains:\n- `*-dal.ts` — data access via `ormify()` + custom queries\n- `*-service.ts` — business logic factory\n- `*-types.ts` — DTOs and type definitions\n- `*-queue.ts` — BullMQ async job handlers and/or cron-manager registrations (when needed)\n- `*-fns.ts` — pure utility functions (when needed)\n\n### Route Handler Pattern\n\nRoutes use Fastify's Zod type provider — schemas auto-generate OpenAPI docs. Each route specifies: `method`, `url`, `config.rateLimit` (using `readLimit` or `writeLimit` presets), `schema` (Zod schemas with `operationId` for OpenAPI), `onRequest: verifyAuth([AuthMode.*])`, and a `handler` that accesses business logic via `server.services.*`.\n\nSee `src/server/routes/v4/secret-router.ts` for a representative router file.\n\n### Auth System\n\nAuth extraction happens in `src/server/plugins/auth/`:\n- `inject-identity.ts` — detects auth mode from request headers and attaches identity to request\n- `verify-auth.ts` — middleware that checks if the request's auth mode is in the route's allowed strategies\n- `inject-permission.ts` — attaches permission context\n- `inject-assume-privilege.ts` — privilege escalation support\n- `superAdmin.ts` — super admin flag injection\n\n**Auth modes** (defined in `AuthMode` enum):\n- **JWT** — user browser sessions (decoded from `Authorization: Bearer` header)\n- **IDENTITY_ACCESS_TOKEN** — machine-to-machine identity tokens\n- **SCIM_TOKEN** — SCIM provisioning tokens\n- **OAUTH** — delegated user tokens from the `oauth-client` module. Same JWT shape and session binding as\n  `JWT`, told apart by an `oauthClientId` claim. See [Delegated OAuth tokens](#delegated-oauth-tokens).\n\n**Deprecated auth modes (do not use in new code):**\n- **API_KEY** — user API keys (from `x-api-key` header). Deprecated — use identity access tokens instead.\n- **SERVICE_TOKEN** — service tokens (Bearer tokens starting with `st.` prefix). Deprecated — use identity access tokens instead.\n\nIf you encounter `API_KEY` or `SERVICE_TOKEN` in existing code, do not replicate them in new routes or services. All new machine authentication should use `IDENTITY_ACCESS_TOKEN`.\n\nToken detection logic in `inject-identity.ts` checks `x-api-key` header first, then parses `Authorization: Bearer` and inspects JWT `authTokenType` field to determine mode.\n\n#### Delegated OAuth tokens\n\nTwo grants issue them: the authorization code flow (consented `scopes`) and the RFC 8693 token exchange\n(`delegation: \"full\"`, no scopes).\n\n**Opting a route in.** `verify-auth.ts` rejects `AuthMode.OAUTH` on any route that doesn't list it; most\nproduct `AuthMode.JWT` routes do. Two things have to hold before adding it. The route must not be an\nadministration route (below). And the handler must actually **check** a CASL ability, because that check\nis where the granted scopes get intersected in: building an ability and throwing it away\n(`getOrgPermission` used as a membership gate), or reading `hasRole(...)` off its return value, narrows\nnothing; `hasRole` is the easy one to miss because it reads like authorization. Rough proxy: nothing\npassing `requireOrg: false` accepts `AuthMode.OAUTH`.\n\n**Administration is read-only for a delegated token.** A delegated token can look at anything the\nauthorizing user can, but it changes nothing under **Administration**: no create, update, or delete, at\neither level. The frontend nav is the boundary, and it is worth opening (`OrgNav.tsx`,\n`OrgSubmenuView.tsx`, `SecretManagerNav.tsx`, `submenus.tsx`) when a new route's side is unclear. The\nsurfaces it covers:\n\n- **org access control** — members, groups, identities (including memberships, templates, and all\n  thirteen `identity-*-auth-router.ts`), org roles, invites, external group-to-role mapping\n- **usage & billing** — `ee/v1/license-router.ts`, `ee/v1/license-v2-router.ts`\n- **audit logs** — the org and project log reads plus audit log streams\n- **org settings** — org update and incident contacts, SSO and provisioning (SAML, OIDC, LDAP, SCIM,\n  GitHub org sync, email domains), networking (gateways, gateway pools, relays), encryption (external\n  KMS, KMIP, KMIP servers, HSM connectors), project templates, sub-orgs, workflow integrations\n  (Slack, Microsoft Teams), and the org-wide EnvKey/Vault migrations on the settings tab\n- **project access control** — memberships for users, groups and identities, project roles, additional\n  privileges, folder grants, project keys, service tokens, and the per-resource equivalents under PAM,\n  cert manager, code signing and PKI applications\n- **project settings** — project update and delete, environments, tags, approval policies, webhooks,\n  trusted IPs, secret validation rules, project KMS, blind index, audit log retention, E2EE upgrade,\n  and the secret scanning `configs` routes\n\nIn practice that means `GET` keeps `AuthMode.OAUTH` and `POST` / `PATCH` / `PUT` / `DELETE` lose it. The\nmethod is the test because `CODE_QUALITY.md` already requires `GET` to be safe, so it needs no per-route\njudgment. Two adjustments to that rule, both small enough to enumerate:\n\n- **A `POST` that only reads keeps it.** Five endpoints are `POST` solely because a lookup needs a request\n  body: `POST /v1/identities/search`, `POST /v2/identities/search`, `POST /v2/identities/search/count`,\n  and the two `memberships/details` routes. All five check a CASL read ability.\n- **A read that hands back credential material loses it**, because the point of closing administration is\n  that a third-party application should not end up holding the org's secrets. Five routes:\n  `GET /v1/sso/oidc/config` (`clientSecret`), `GET /v1/ldap/config` (`bindPass`),\n  `GET /v1/workspace/:projectId/kms/backup` (the project's KMS backup blob), and Slack\n  `GET /install` / `GET /reinstall`, which gate on `OrgPermissionActions.Create` and exist only to start\n  an install. Everything else reads through a sanitized schema — `sanitizedClientSecretSchema` returns a\n  prefix, `sanitizedExternalSchemaForGetById` drops provider credentials, `ScimTokensSchema` carries no\n  token. Check the response schema, not the route name, before opening a new administration read.\n\nThree things are first-party outright, because they sit outside an org and the nav boundary doesn't\nreach them:\n- account self-management (user, password, MFA, sessions, login, signup, notifications, announcements)\n- super-admin routes (`v1/admin-router.ts`), where `verifySuperAdmin` reads\n  `user.superAdmin` rather than an ability\n- `POST /v2/organizations`, and the org reads that return more than the token's org: `GET /v1/organization`,\n  `GET /v1/organization/accessible-with-sub-orgs`, `GET /v1/organization/:organizationId`. All pass\n  `requireOrg: false`, so nothing narrows them\n\nEverything else that used to be listed here individually — OAuth client CRUD, assume-privilege, deleting\nan org or sub-org, minting SCIM tokens, KMIP client certificates or gateway / relay / KMIP-server\nenrollment tokens, registering identity auth methods — is an administration write, and closed by the rule\nabove rather than by its own exception. Two of those reasons still generalize, so keep them in mind when a\n**product** route does the same thing:\n\n- **A route that leaves behind something able to authenticate later can't accept a delegated token.**\n  Revocation reaches only the sessions tagged with `getOauthClientSessionUserAgent`, so a credential or an\n  auth method bound to a key the caller holds survives deleting the application and rotating its secret,\n  and authenticates as a principal no OAuth scope intersects. Whether the route hands secret material back\n  is beside the point, so start any new route that writes a trust anchor (bound issuer, signing key, CA,\n  cloud principal) first-party.\n- **Nor can a route that reissues the caller's session.** `DELETE /v2/organizations/:organizationId`\n  returns a fresh pair from `loginService.generateUserTokens`, which takes no delegation parameter, so a\n  delegated caller would exit with a first-party session.\n\n**Delegation markers.** A token carries `scopes` or `delegation: \"full\"`, never both\n(`oauth-client-types.ts`). `permission-service` intersects the user's CASL rules with `scopes`\n(`applyOauthScopeToOrgRules` / `applyOauthScopeToProjectRules`). `inject-identity.ts` sets\n`RequestContextKey.OauthScopes` for every scope-narrowed token (even `[]`, which denies everything) and\nleaves it unset only for full delegation. Matching full delegation **positively** is what makes a dropped\nclaim fail closed instead of promoting the token.\n\n**The token endpoint's error contract is RFC 6749 §5.2, not the house envelope**, because generic OAuth\nlibraries branch on the `error` code. `OauthTokenError` (`oauth-token-error.ts`) carries the code and\nderives the status from it (400; 401 for `invalid_client`, 500 for `server_error`), and the shared error\nhandler renders `{ error, error_description }`. When extending it:\n- The handler's `try`/`catch` is the endpoint's only exit, and every `return` inside it is **awaited**: a\n  bare `return promise` would resolve outside the `catch` and escape in the house envelope.\n- `toOauthTokenError` defaults by **error class**, never message text: `UnauthorizedError` and\n  `ForbiddenRequestError` take the rejected-grant code for that grant (`invalid_grant` on the redirect and\n  refresh grants, `invalid_request` on token exchange, per RFC 8693 §2.2.2), `BadRequestError` takes\n  `invalid_request`, and anything else becomes `server_error`, whose response carries no detail and whose\n  original is logged. Both refusal classes are listed because the exchange rejects an unusable subject in\n  two vocabularies: `exchangeSubjectToken`'s own membership checks raise `UnauthorizedError`, while\n  `getOrgPermission` raises `ForbiddenRequestError` for the same kind of refusal. A new way to refuse a\n  subject belongs in one of those two classes, or it lands in `server_error` as an unexplained 500.\n- Throw `OauthTokenError` directly where that default is wrong: client authentication\n  (`invalid_client`), a grant the client doesn't hold (`unauthorized_client`), a redirect-URI or PKCE\n  mismatch (`invalid_grant`), the unsupported `scope` (`invalid_scope`) and `audience`/`resource`\n  (`invalid_target`) parameters, and org misconfiguration or IdP outages (`server_error`).\n- The route sets `attachValidation` so a schema failure reaches the handler; an unrecognized `grant_type`\n  earns `unsupported_grant_type`.\n- `toErrorDescription` sanitizes and bounds `error_description` at the render boundary, so no throw site\n  has to remember RFC 6749's printable-ASCII restriction.\n- Messages shared with a management route (the OIDC-config checks) take an error factory rather than\n  throwing, since the two callers owe the same explanation in different envelopes.\n\n**Token exchange trusts the org's OIDC SSO config, not per-client configuration**\n(`oauth-token-exchange-fns.ts`), so the issuers that can vouch for a user are the ones that can already\nlog them in. The one per-application field is `tokenExchangeAudience`, and it does the security work:\nwithout it, any token that issuer signed for anything in the estate is exchangeable. Enabling the grant,\nchanging the audience, and rotating the secret each need `OrgPermissionSsoActions.Edit` on top of the usual\n`OauthClients` check (`checkSsoConfigPermission`), rotation included, since its response is a working\ncredential for acting as any of the org's users.\n\n`getOrgPermission` is **not** a membership check: the org-scope query in `permission-dal.ts` never reads\n`Membership.isActive` or `Membership.status`. Every other user token came from a browser login, where\n`selectOrganization` rejects an inactive membership; the exchange has no login in front of it, so\n`exchangeSubjectToken` checks membership state itself. Any future non-interactive way of minting a user\ntoken needs the same check.\n\n**Withdrawing an exchange application's authority revokes the tokens it already issued.** Deleting the\nclient, rotating its secret, and any update that narrows the exchange's trust (dropping the grant,\nchanging `tokenExchangeAudience`, switching `tokenExchangeIdpSatisfiesMfa` off) all call\n`revokeSessionsByUserAgent`. `hasWithdrawnTokenExchangeTrust` and `hasClientAuthorityChanged` guard the\nsame fields, so a new field belongs in both. The test is whether it carries federation trust, not merely\nwhether it can be narrowed. `accessTokenTTL` is in neither: it only decides how long a new token lasts, so\nrevoking on a routine edit would surprise, and guarding it would fail in-flight exchanges. Widening\n(turning the MFA declaration on) revokes nothing. Rotation revokes for exchange clients only, where the\nsecret alone mints tokens; in the redirect flow it has to be paired with a code or refresh token, so\nblanket revocation would just sign everyone out. The session tag is per client, which is unambiguous\nbecause `assertValidOauthClientGrantConfig` rejects the exchange grant alongside `authorization_code`, so\na service needing both registers twice.\n\nThat sweep only reaps sessions that already exist, so **the exchange rechecks the client after creating\nits own session** (`findByIdForUpdate` + `hasClientAuthorityChanged`, re-running the sweep). The other\ngrants read an existing session rather than creating one, so they don't need it. The recheck has to be a\n**locking** read on the primary, and both halves of that are load-bearing: each withdrawal path writes the\nclient and sweeps sessions inside one transaction, so a plain read still sees the pre-withdrawal row until\nthat transaction commits, and would clear a token whose session the sweep has already scanned past. A\nreplica read has the same hole for the length of the replication lag. Keeping the write and the sweep\natomic is what makes the lock the fix here; splitting them so the write commits first would reopen a\nwindow where the sweep can fail on its own and leave the tokens live.\n\n**Everything the exchange fetches from the IdP is cached for 10 minutes; nothing read from our own\ndatabase is**, so an admin editing the SSO config takes effect on the next request while the provider\nnever becomes a synchronous dependency of the middleware's own request path. The cache invariants\n(rejections evicted, in-flight promise shared, expiry rebuilding the SSRF-pinned agent) are documented in\n`oauth-token-exchange-fns.ts`.\n\n### Resolving Users From Provisioning Identifiers\n\nEvery invite / removal path resolves a person by `users.username` (which always equals their email),\nnever by `users.email` and never by a join. An external provisioning system, though, often knows\npeople only by the identifier its IdP asserts, a UPN like `m249913@one.example.com` rather than the\nmailbox `robert@example.com`. SSO login already records that identifier as\n`user_aliases.externalId`, so the two are reconcilable without making `username` diverge from\n`email`.\n\n`resolveUsersBySsoExternalId` in `src/services/user-alias/user-alias-fns.ts` is the one place that\ndoes it, backed by `userAliasDAL.findBySsoExternalIds`. Callers run it **only after an exact\n`username` lookup has missed**, so an alias can never shadow a real account. It is wired into\n`$getUsers` (`membership-user-service.ts`, the single funnel for org, project, and cert-manager\ninvites), `project-membership-service.ts` (removal and membership read-back), and PAM's\n`addProductUserMembers`.\n\nFour invariants, each load-bearing:\n\n- **Scope by `orgId` *and* `aliasType`.** `user_aliases.orgId` is NULL for the global\n  google/github/gitlab aliases, so `whereIn(\"orgId\", ...)` excludes them outright and the\n  `ORG_SCOPED_USER_ALIAS_TYPES` filter is the second lock. Without both, an org admin could name a\n  user in another tenant.\n- **Match `externalId` exactly, never folded.** It is a case-sensitive identifier (OIDC Core defines\n  `sub` that way, as does SAML for nameID) and is stored verbatim, so folding case could collapse two\n  distinct IdP subjects onto one identifier. The known consequence: because both invite routes\n  lowercase their input (`sanitizeEmail`, and the `.refine` on `usernames`), an IdP that asserts\n  mixed-case identifiers cannot be provisioned against at all. Supporting those means relaxing that\n  input validation so the exact identifier survives to the query, not loosening the comparison.\n  `adoptProvisionedShadowUser` does not fold either. It derives its lookup key with `sanitizeEmail`\n  because it searches the `users.username` namespace, where lowercase is canonical, but it then\n  **refuses any identifier that is not already canonical** rather than adopting on the folded match.\n  It once did adopt, and that was a real hole: a subject differing only by case is a different\n  subject, so it could take over the placeholder provisioned for another one and inherit its grants.\n  Folding bought nothing anyway, since the alias written afterwards is verbatim and this exact-match\n  lookup could never find it again, stranding the grant where provisioning cannot manage it.\n- **Ambiguity is an error.** Nothing constrains `(externalId, aliasType)` to be unique for the\n  org-scoped types (only the social ones have a partial unique index), so an identifier can reach two\n  users. Picking one would be a guess about which human it names, and the cost of guessing wrong is\n  granting access to the wrong person. Several aliases on the *same* user are not a conflict.\n- **Dedupe resolved users by `id`.** An identifier now reaches a user by either their username or\n  their alias, so one request can name the same person twice. Undeduped, that violates\n  `membership_unique_user_org` and surfaces as a 500.\n\nNothing constrains a user to one alias per `(orgId, aliasType)`, and SSO login mints a new one\nwhenever the asserted subject differs from what SCIM last wrote, so **a SCIM read must match a\n`userName` against every one of a user's aliases, not the newest**. Matching only the newest made a\nprovisioned user vanish from `GET /Users?filter=userName eq \"...\"` the first time they logged in\nunder a different subject, and the IdP answered that empty lookup by provisioning them again. It was\nintermittent because `replaceScimUser` rewrites `externalId` on *all* of a user's aliases, so the\nnext PUT healed it until the next login. `$buildScimMembershipQuery` (`org-dal.ts`) therefore\nanswers every `userName` comparison with an `EXISTS` over the user's aliases, so the predicate is per\nuser rather than per alias row. That matters for negation: evaluated per row, `userName ne \"x\"` or\n`not (userName eq \"x\")` would keep a user through their other alias, and a member with no alias at\nall would fall out through a NULL comparison. The parser (`lib/knex/scim.ts`) lets an attribute\nresolve to a handler instead of a column for exactly this. Display is separate: the query still\njoins every alias and collapses the fan-out with `DISTINCT ON`, ranked so the alias row that\nsatisfies the filter wins and the newest is the fallback, which is how a lookup by an older alias\nechoes that alias back. The list query also carries a total order, because an IdP walking\n`startIndex`/`count` over an unordered result loses users the same way.\n\nA related case sits on the login side: provisioning can name someone before they have ever logged\nin, leaving a placeholder account keyed on the identifier instead of the mailbox.\n`adoptProvisionedShadowUser` (same file, wired into `oidcLogin`'s no-alias branch) adopts that row\nand rewrites it to the asserted mailbox rather than creating a second account. It refuses anything a\nhuman has claimed (accepted, email-verified, holding a password), anything already bound to an IdP\n(any alias, any org), ghosts, anything whose identifier is not already canonical (see above), and\nanything without a membership in the org doing the login.\n\nOne case is a refusal to *log in* rather than a refusal to adopt: a placeholder whose membership in\nthe login org is **inactive**. Declining is not neutral there, because the caller reads a `null` as\n\"no placeholder\" and creates a second account with a fresh active membership, handing a deactivated\nperson their org back (`selectOrganization` then accepts it). So the inactive check is resolved\nbefore every remaining decline — the alias check and the cross-tenant check both sit after it — and\nit throws the same `ForbiddenRequestError` an already-aliased deactivated member gets.\n\nIt also refuses a placeholder that holds an org membership **outside** the login org's own sub-org\nfamily, and that one is the security-critical check rather than a tidiness one. The username lookup\nthat finds the placeholder is global, so a second tenant that invited the same identifier shares the\nrow; adopting it would hand the login org's IdP subject that tenant's memberships, and\n`selectOrganization` accepts a membership in any status (promoting `Invited` to `Accepted` on\narrival), so nothing downstream stops the inherited access from being used. A project membership\nalways implies an org membership in the same org, so the org-scope check covers project access too.\n\nAdoption also recovers from a unique violation on `users.username`, because the caller's preceding\nread is not a lock. That recovery has to run inside a savepoint (`tx.transaction()`, which knex\ncompiles to `SAVEPOINT`/`ROLLBACK TO SAVEPOINT` on the same connection): Postgres aborts the whole\ntransaction on a constraint violation, so an unscoped retry would fail with `25P02`, taking the\ncaller's remaining alias and membership writes with it. SAML and LDAP have structurally identical\nbranches and are deliberately not wired up.\n\nBecause adoption rewrites an existing account's `username` and `email`, it emits an\n`OIDC_PROVISIONED_PLACEHOLDER_ADOPTED` audit event carrying the before and after, not just an\napplication log: if one of the refusal checks above ever regresses, the audit trail is what makes it\nfindable. That event must never fire on the unique-violation recovery path, where the returned user\nis whoever won the race rather than a rewritten placeholder. `adoptProvisionedShadowUser` draws that\nline by returning `adoptedFromUsername: null` for the yield, and the caller keys the audit log on\nit.\n\nThat trail is best-effort, not guaranteed. `audit-log-queue.ts` drops every entry at push time when\n`plan.auditLogsRetentionDays` is 0, which is the default for a self-hosted instance with no\naudit-log entitlement, so on those deployments only the `logger.info` line survives an adoption.\n(`null` is the License Server's \"No limit\" and keeps everything; see `audit-log-retention-fns.ts`.)\nDo not special-case this event past the retention gate; treat the application log as the floor and\nthe audit event as the addition for licensed instances.\n\n### Profile Sync From The IdP (SSO-enforced orgs)\n\nAn IdP keyed on a stable identifier lets someone change mailbox and display name without changing\nwho they are, so our copy of both goes stale: notification mail goes to an address that no longer\nexists, and every audit entry written from then on records it. `syncSsoUserProfile`\n(`src/services/user-alias/user-alias-fns.ts`) carries the asserted email and name onto the account,\nand is called from all three login services right after `ensureSsoAccountVerified`, outside the\ncaller's transaction, with its result flowing into the session.\n\nThe gate is `organization.authEnforced` **and** a verified alias, and both halves matter. Enforcement\nis the org saying the IdP is authoritative for identity, which is the same claim that already skips\nemail verification at signup. The verified alias is the proof that the IdP controls *this* account:\nan unverified alias asserting an unrecognized email is exactly what `isStaleSsoAlias` exists to\ncatch, and reading that as a rename would let a stale alias rewrite somebody else's account. One\nconsequence to know: a legacy unverified alias whose email changed before its next login is stale,\nso it gets the email-verification fallback (a code sent to the dead mailbox) rather than a sync.\n\nThree invariants:\n\n- **It never fails a login.** A rename that could not be applied leaves stale data, which is what we\n  already had; a throw locks the person out of an org that has no other way in. Every failure path\n  returns the unchanged user.\n- **It only renames an address the org owns, onto another address the org owns.** Both halves are\n  checked in the helper against the org's verified domains. The user row is global rather than\n  org-scoped, so without the first half an org could rename an account that merely carries one of\n  its aliases and rewrite the identity every other org of that user sees. Every login path\n  establishes both already (`verifyEmailDomainOwnership` is a positive check: the domain must be\n  verified *for this org*, and the alias branch runs it against the existing account's username),\n  so the helper's copy is there to keep the rename from outliving those checks. It is unreachable\n  today, and audits the skip anyway (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"domain-not-owned\"`) so a refactor that removes a caller's check leaves a trail rather\n  than a silently stale account.\n- **It never renames onto an occupied address.** `users.username` is globally unique and the row is\n  global rather than org-scoped, so the asserted address may already be a personal signup or an\n  unmerged duplicate. The conflict is recorded (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"address-taken\"`) and the email is left alone; the name still syncs. The preceding read\n  is not a lock, so a unique violation lands on the same path.\n\nSCIM is the other half of the same story and moves with it. `updateScimUser` / `replaceScimUser`\nrejected every email change outright, which left the data stale *and* put the provisioning job in a\npermanent error state (Entra quarantines after repeated failures). Both now accept the change under\nthe same `authEnforced` gate via `$resolveScimEmailChange`, and answer an occupied address with\n`409 uniqueness` rather than a constraint-shaped 500. Self-service email change\n(`user-service.ts`) is refused when the next login would overwrite it anyway, which\n`$getManagedEmailReason` narrows to the case that actually would: the account's own address sits on a\ndomain one of its SSO-enforced orgs has verified. Membership in an enforced org is not the test, since\nthe user row is global and an address outside that org's domains is one `syncSsoUserProfile` will not\ntouch. SCIM stays a blanket refusal, because the directory provisions the address either way.\n\n### Permission System (CASL)\n\nUses CASL (`@casl/ability`) with MongoDB-style rules. Permission logic lives in `src/ee/services/permission/`:\n- `permission-service.ts` — factory that builds CASL abilities from user/identity roles\n- `project-permission.ts` — defines project-level permission actions and subjects\n- `org-permission.ts` — defines org-level permission actions and subjects\n\n**Project permission actions** include standard CRUD plus specialized ones like `DescribeSecret` (see metadata without value), `ReadValue`, `GrantPrivileges`, `AssumePrivileges`, `Lease` (for dynamic secrets). See `ProjectPermissionActions`, `ProjectPermissionSecretActions`, `ProjectPermissionDynamicSecretActions`, and `ProjectPermissionIdentityActions` enums in `project-permission.ts`.\n\nBuilt-in roles: `Admin`, `Member`, `Viewer`, `NoAccess`. For PAM and Agent Vault `getPredefinedRoles` (`project-role-fns.ts`) returns only `Admin` and `Member`, because their permission dispatch resolves every other slug to the member set; the role factory delegates to that one function, so every role picker follows. Custom roles use unpacked CASL rules stored in the database. Rules can include conditions with operators `$IN`, `$EQ`, `$NEQ`, `$GLOB` (for pattern matching like `prod-*`). See `PermissionConditionSchema` in `permission-types.ts`.\n\n**Privilege boundaries mean different things on the two privilege systems.** `organizations.shouldUseNewPrivilegeSystem` (default `true`, backfilled `false` for orgs predating March 2025) picks which. `validatePrivilegeChangeOperation` / `assertRoleSetBoundary` (`permission-fns.ts`) are the shim: on the new system they collapse to `actorPermission.can(action, subject)`, on the legacy one they run `validatePermissionBoundary`, requiring the actor to out-rank every role the target holds. So a boundary at a call site that already gated on the same action is a no-op for new-system orgs and the only protection for legacy ones. Two consequences: \"the route checks the action\" is not a substitute for a boundary, and adding one is not a behavior change for most tenants. Prefer `getOrgPermissionByRoles` / `getProjectPermissionByRoles` + `assertRoleSetBoundary` over fetching the target's merged ability. It bounds each role separately, and it avoids `getProjectPermission`, which stamps whichever actor it was called for onto the request context the audit log reads.\n\n**Identity auth-method access goes through `assertIdentityAuthAccessAllowed`** (`src/services/identity/identity-auth-permission-fns.ts`), because repointing an identity's auth trust lets the caller authenticate as that identity. It's both the gate and the boundary: it checks the identity action itself on both privilege systems, and on legacy also makes the actor out-rank every grant the target holds. The boundary alone isn't enough, since it passes for any target you cover, and for an org identity it only sees org-scope grants, so a no-access org identity that's admin in projects would be open to anyone. Callers don't repeat the action check. The 13 auth services and the template update call it once per attach/update/revoke, outside the `identity.projectId` branch so both scopes are covered, and a new auth method should too. Only the fine-grained action gates these paths, at both scopes; the old coarse org checks (`edit`, `create`, `read`, `delete`) are gone. Credential issuance (`createTokenAuthToken`, `createUniversalAuthClientSecret`) goes through it under `create-token`, since minting a credential is the same escalation as repointing the trust, and so do the per-credential paths: `updateTokenAuthToken` under `create-token`, `getUniversalAuthClientSecrets` / `getUniversalAuthClientSecretById` under `get-token`, and `revokeUniversalAuthClientSecret` under `delete-token`. **`PROJECT_ACTION_BY_ORG_ACTION` must map each org action to its project equivalent**, since project identities get checked against the mapped one. `revokeTokenAuthToken` and `clearUniversalAuthLockouts` are still unbounded in both scopes (and the former gates on `edit` where its UA counterpart gates on `delete-token`); that predates the helper and is a deliberate gap, not an oversight to copy. The target's grants come from `permissionService.getActorGrantAbilities`, not from a membership lookup, and it returns one ability per grant rather than per role. Two things a role-slug resolver misses: a group-derived membership carries a NULL `actorIdentityId`, so `membershipIdentityDAL.getIdentityById` never sees it, and an additional privilege carries a raw permission blob with no slug at all, so no `*PermissionByRoles` path can express it. Either omission clears an actor that out-ranks the target's direct roles but not its effective access. `getActorGrantAbilities` reads the same `permissionDAL.getPermission` query the ability itself is built from, so the bounded set cannot drift from the effective one.\n\n**Auth template propagation is serialized with row locks on `identity_auth_templates`.** A template edit rewrites every linked identity's auth, and a link copies the template's values onto the identity row, so the two must not interleave: a link landing between the edit's permission loop and its writes would be rewritten by an actor never checked against it, and an edit landing between a link's validation and its insert would leave the identity holding values the template no longer has. `updateTemplate` and `deleteTemplate` take the row `FOR UPDATE` as the first statement of their transaction, and the edit re-reads usages through `tx` under that lock and returns a 409 if an identity appeared since the checked set was built; its propagation writes are also scoped to the checked ids so the privilege invariant does not rest on the lock. Every path that copies template values onto an identity (the three attaches, and the three identity updates when they load a template) calls `assertTemplateUnchangedForLink` (`ee/services/identity-auth-template/identity-auth-template-fns.ts`) as the first statement of its transaction: a `FOR SHARE` read plus an `updatedAt` comparison against the row it validated outside the transaction, 409 on mismatch. Lock order is template row first, identity row second; unlink and non-linking identity updates take no template lock and never wait on it, so no cycle is possible. Nothing slow may sit under either lock, which is why LDAP attach runs `testLDAPConfig` before its transaction opens. A new path that copies template values onto an identity must call the helper inside its transaction.\n\n**Deny an RBAC check by throwing through CASL, never with `ForbiddenRequestError`.** `audit-log-permission-denied.ts` records a permission-denied audit event for every CASL `ForbiddenError` and `PermissionBoundaryError` a request throws, and skips plain `ForbiddenRequestError` because most of those are auth-mode, plan and ownership refusals. A service that calls `permission.can()` and throws `ForbiddenRequestError` itself therefore produces no denial event, and nothing fails to tell you. When the check has a fallback (a second action, an approver, an application grant), test the fallbacks first and make `ForbiddenError.from(permission).throwUnlessCan(...)` the last check, so the denial is recorded with a real ","DESIGN.md":"# Infisical Design System (v3)\n\nThis document captures the v3 visual language and product voice used across\nInfisical. It is the single reference for engineers, designers, and AI coding\nagents producing new UI or user-visible copy.\n\n**Source of truth for tokens and themes:** [`frontend/src/index.css`](frontend/src/index.css) (`@theme` blocks and theme overrides).\n**Canonical semantic reference:** [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx).\n**Canonical page references:** [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) and [`AccessControlPage`](frontend/src/pages/project/AccessControlPage).\n**Component usage reference:** Start with the sibling story in [`v3/generic`](frontend/src/components/v3/generic) or [`v3/platform`](frontend/src/components/v3/platform) when one exists, then check source and consumers. Some exports, including `CopyButton`, `DataGrid`, and `Sidebar`, have no sibling story; the [generic](frontend/src/components/v3/generic/index.ts) and [platform](frontend/src/components/v3/platform/index.ts) barrels are the complete export catalogs.\n\n---\n\n## 1. Visual Theme & Atmosphere\n\nInfisical is a security tool for operators. The interface reads like\ninfrastructure: dense, calm, and legible. Decorative treatments are reserved\nfor brand-forward surfaces such as authentication and onboarding; the core\nproduct remains utilitarian. Dark is the default medium; the page canvas is\n`--color-page`. Light mode is implemented through semantic token overrides and\nthe theme provider (with intentionally forced-dark authentication paths).\nCheck shared surfaces in both themes rather than hard-coding dark contrast.\n\nColor carries **meaning before brand**. A danger badge is red because the\naction is destructive, not because red is the accent. A project-colored button\nsignals project scope, not visual variety. Designers pick intent; hex values\nfollow.\n\nDepth is drawn primarily with borders and surface tones; the shared v3 `Card`\nadds a subtle `shadow-xs`. Motion is restrained and clarifies state changes;\ncontrol and collapse transitions often use 200ms, while overlays own their\nentry/exit timing. Respect reduced-motion behavior rather than assuming all\nanimations share one implementation. Secret values are masked by default;\nrevealing one is an intentional act.\n\n**Key characteristics:**\n\n- Dark default with light semantic overrides; `--color-page` page canvas\n- Semantic-first color (danger / success / warning / info / neutral)\n- Scope- and product-aware (org / sub-org / project / admin / PAM / Agent Vault)\n- Border-first depth with the shared `Card`'s `shadow-xs`; overlapping labels\n  retain the shared Badge styling\n- IBM Plex Sans for product UI, Alliance No. 2 for headings, and JetBrains\n  Mono for monospaced content\n- Secrets masked by default; reveal is an act\n\n## 2. Color Palette & Roles\n\nAll colors are defined as CSS custom properties in\n[`frontend/src/index.css`](frontend/src/index.css) and consumed via Tailwind v4\nutilities (`bg-org`, `text-danger`, etc.). Never introduce a hex that is not\nin this file.\n\n### Scope colors (hierarchy)\n\nUsed to signal the scope a surface, badge, or action belongs to.\n\n| Scope            | Token             |\n| ---------------- | ----------------- |\n| Organization     | `--color-org`     |\n| Sub-Organization | `--color-sub-org` |\n| Project          | `--color-project` |\n| Admin            | `--color-admin`   |\n\nPAM, Agent Vault and Secret Scanning are product identities rather than\nadditional levels in the org/project hierarchy. Use `--color-product-pam`,\n`--color-product-av` and `--color-product-ss` when the current product calls\nfor them; `PageHeader` and the org sidebar map those project types to these\naccents, while `Button` exposes `pam`, `av` and `ss` variants. Check each component's supported variants instead of treating all\nscope and product variants as interchangeable.\n\n### Semantic colors\n\n| Intent  | Token             | Use                                          |\n| ------- | ----------------- | -------------------------------------------- |\n| Success | `--color-success` | Healthy states, completed rotations          |\n| Info    | `--color-info`    | Informational states, external documentation |\n| Warning | `--color-warning` | Attention-warranting states, stale items     |\n| Danger  | `--color-danger`  | Destructive actions, errors, expired access  |\n| Neutral | `--color-neutral` | Disabled, muted, \"empty\" states              |\n\n### Surface & chrome\n\n| Role              | Token                     |\n| ----------------- | ------------------------- |\n| Page canvas       | `--color-page`            |\n| Background        | `--color-background`      |\n| Foreground text   | `--color-foreground`      |\n| Card surface      | `--color-card`            |\n| Popover / Sheet   | `--color-popover`         |\n| Container         | `--color-container`       |\n| Container (hover) | `--color-container-hover` |\n| Border            | `--color-border`          |\n| Focus ring        | `--color-ring`            |\n| Accent text       | `--color-accent`          |\n| Muted text        | `--color-muted`           |\n| Label text        | `--color-label`           |\n\nThe `mineshaft-*` scale (50–900) is the underlying neutral ramp; see\n`index.css` for the full list. Prefer semantic tokens (`card`, `border`,\n`accent`) over raw mineshaft values.\n\n### Product-area accents (secret-manager)\n\nReserved for resource types in the secret management product:\n`--color-folder`, `--color-secret`, `--color-dynamic-secret`,\n`--color-import`, `--color-secret-rotation`, `--color-override`,\n`--color-proxied-service`.\nDo not repurpose these for generic UI.\n\n### Variant treatments\n\nChoose the component variant by semantic intent or actual scope/product, not\nby a preferred hue. Named semantic and scope tints commonly use these recipes:\n\n- **Badge** — `bg-<c>/15 border-<c>/10 text-<c>`, hover `bg-<c>/35`\n  (see [`Badge.tsx`](frontend/src/components/v3/generic/Badge/Badge.tsx))\n- **Button** — `bg-<c>/10 border-<c>/25 text-foreground`, hover `bg-<c>/15 border-<c>/30`\n  (see [`Button.tsx`](frontend/src/components/v3/generic/Button/Button.tsx))\n\n`Button` product variants (`pam`, `av`, `product`) use `/25` backgrounds and\n`/30` borders. Neutral/default, outline, ghost, shaded, and link treatments\nhave their own contracts; the tint recipes are not universal. Read the source\nfor exact classes and supported variants.\n\n## 3. Typography\n\nTypography uses three intentional families:\n\n- **IBM Plex Sans** (`font-sans`, `--font-sans`) is the default product UI face.\n  Body copy, forms, tables, buttons, labels, and navigation inherit it. Real\n  400, 500, 600, and 700 weights are bundled for UI hierarchy.\n- **Alliance No. 2** (`font-alliance`, `--font-alliance`) is the heading face.\n  Semantic headings and shared page, card, dialog, alert dialog, and sheet\n  titles use its bundled Regular weight. Do not use it for arbitrary bold body\n  text or request synthetic heavier weights.\n  Product and project tile names are titles, not descriptions. Use `CardTitle`\n  for them. Do not override semantic headings or shared titles with medium,\n  semibold, or bold weights; express hierarchy with size and spacing instead.\n- **JetBrains Mono** (`font-mono`, `--font-mono`) is the functional and\n  decorative mono. Use it for code, secret values, identifiers, timestamps,\n  logs, and short technical microcopy.\n\n| Role                    | Class                                                                     | Notes                                                              |\n| ----------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| Page title (h1)         | `font-alliance text-2xl font-normal underline underline-offset-4 decoration-<scope>/90` | In `PageHeader`; scope icon (size 26) sits inline before the title |\n| Page description        | `text-label`                                                              | Sits under the title, separated by the header's `gap-2`            |\n| Card title              | `font-alliance text-lg font-normal leading-none`                           | `flex gap-1.5` so badges can sit inline                            |\n| Card description        | `text-sm text-accent`                                                     |                                                                    |\n| Body                    | `text-sm`                                                                 | Default for table cells, form values, dialog content               |\n| Label / meta            | `text-xs text-accent`                                                     | Field labels, table column captions, metadata                      |\n| Field helper / feedback | `text-2xs leading-snug`                                                   | Defaults via `FieldDescription` and `FieldFeedback`                |\n| Badge                   | `text-xs` (auto, via `Badge`)                                             | Never override                                                     |\n| Button                  | `text-sm` (md/sm/lg), `text-xs` (xs)                                      | Auto via `Button` sizing                                           |\n\nOn brand-forward surfaces, a short phrase may use the `text-highlight`\nutility to create the solid project-yellow text block. Keep the phrase short,\nuse it as typographic emphasis rather than status or scope, and do not apply it\ninside routine product UI.\n\nSentence case for descriptions, helper text, and empty states. Title Case for\npage titles, card titles, dialog titles, sheet titles, button labels, badge\nlabels, and dropdown menu items. See §8 for voice rules on copy itself.\n\n## 4. Component Stylings\n\nPrefer v3 components from [`frontend/src/components/v3/`](frontend/src/components/v3)\nfor new UI. The v2 library is legacy; use it for new work only when v3 does not\ncover the required behavior. Existing v2 consumers marked blocked in the\n[lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) need\nconsumer-specific parity checks before replacement, even on mixed pages.\n\nFor exact tokens, classes, and supported variants, read component source and\nthe story when present; stories may not enumerate every implemented variant.\n\n### Reading the stories\n\nWhere present, stories may use these conventions:\n\n- **`Variant: X`** stories — one per prop-driven variant (e.g. `Variant: Outline`).\n- **`Example: X`** stories — composition recipes (e.g. `Example: With Header`,\n  `Example: Inside Card / Sheet / Dialog`).\n- Story descriptions or component-level metadata may explain when to use a composition.\n\nUse the closest example as a starting point, not a mandatory composition.\nSome stories use other names or omit descriptions; check component props,\nexports, and representative consumers when guidance is absent.\n\nRun Storybook with `cd frontend && npm run storybook` (port 6006) to preview.\n\n### Component inventory\n\nUse these tables to find the component for a given intent. For props,\nvariants, sizes, and class lists, open the source and any available story.\nThe [generic](frontend/src/components/v3/generic/index.ts) and\n[platform](frontend/src/components/v3/platform/index.ts) barrels also expose\nspecialized primitives not listed here (for example `CodeBlock`, `CopyButton`,\n`RadioGroup`, `TagsInput`, `FileDropzone`, `DurationInput`, `ColorPicker`).\n\n#### Actions\n\n| Component                                                                       | Reach for this when…                                                                                                                                                                                                                          |\n| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [`Button`](frontend/src/components/v3/generic/Button/Button.tsx)                | A text-bearing button — primary or secondary action.                                                                                                                                                                                          |\n| [`ButtonBadge`](frontend/src/components/v3/generic/Button/ButtonBadge.tsx)      | A short decorative flag attached to a Button corner—such as \"Last Used\", \"New\", or \"Recommended\". It inherits Badge typography, sizing, radius, border, and casing; only its corner positioning and solidified semantic tint are specialized. |\n| [`IconButton`](frontend/src/components/v3/generic/IconButton/IconButton.tsx)    | A square icon-only button — toolbars, row actions, compact triggers. Always `aria-label`.                                                                                                                                                     |\n| [`ButtonGroup`](frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx) | Visually join related controls — toolbars, segmented controls, split buttons, key-value chips.                                                                                                                                                |\n| [`Dropdown`](frontend/src/components/v3/generic/Dropdown/Dropdown.tsx)          | An action menu — overflow `⋯`, split-button alternates, contextual lists.                                                                                                                                                                     |\n\n#### Forms\n\n| Component                                                                                                                                 | Reach for this when…                                                                                        |\n| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| [`Field`](frontend/src/components/v3/generic/Field/Field.tsx)                                                                             | Compose labeled or validated form controls with the slots they need. `FieldContent`, description, and error are optional; a standalone filter/search control need not be a form field. |\n| [`Label`](frontend/src/components/v3/generic/Label/Label.tsx)                                                                             | Standalone form label outside a `Field`.                                                                    |\n| [`Input`](frontend/src/components/v3/generic/Input/Input.tsx) / [`TextArea`](frontend/src/components/v3/generic/TextArea/TextArea.tsx)    | Single-line / multi-line text entry.                                                                        |\n| [`InputGroup`](frontend/src/components/v3/generic/InputGroup/InputGroup.tsx)                                                              | Input with left/right addons — search bars, prefixed values.                                                |\n| [`Select`](frontend/src/components/v3/generic/Select/Select.tsx)                                                                          | Non-searchable single-select with a short, known option list.                                               |\n| [`Combobox`](frontend/src/components/v3/generic/Combobox/Combobox.tsx)                                                                    | Searchable single- or multi-select, including grouped/rich rows and inline or dialog creation when its contract fits. |\n| [`ReactSelect`](frontend/src/components/v3/generic/ReactSelect/index.ts)                                                                  | Deprecated compatibility for consumers needing react-select-specific overrides; see the lifecycle ledger. |\n| [`Toggle`](frontend/src/components/v3/generic/Toggle/Toggle.tsx) / [`Checkbox`](frontend/src/components/v3/generic/Checkbox/Checkbox.tsx) | Boolean toggle / multi-select boolean.                                                                      |\n| [`Calendar`](frontend/src/components/v3/generic/Calendar/Calendar.tsx)                                                                    | Date / multi-date / range picker primitive.                                                                 |\n| [`DateRangeFilter`](frontend/src/components/v3/platform/DateRangeFilter/DateRangeFilter.tsx)                                               | Platform date-range filter with presets — for filter bars.                                                  |\n| [`SecretInput`](frontend/src/components/v3/platform/SecretInput/SecretInput.tsx)                                                           | Platform secret-value editor with mask toggle and `${var}` highlighting.                                    |\n| [`PasswordGenerator`](frontend/src/components/v3/platform/PasswordGenerator/PasswordGenerator.tsx)                                         | Platform password generator against project secret-validation rules.                                        |\n| [`EmailInput`](frontend/src/components/v3/platform/EmailInput/EmailInput.tsx)                                                              | Platform chip field for one or more free-text email addresses. Validate it with `emailListSchema` from `@app/helpers/email`; check single addresses or picker-created invitees with `isValidEmail` rather than a local regex or `z.string().email()`. |\n\n#### Containers & overlays\n\n| Component                                                                       | Reach for this when…                                                                                                         |\n| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| [`Card`](frontend/src/components/v3/generic/Card/Card.tsx)                      | Default section container — tables, filters, forms, empty states all live in a Card.                                         |\n| [`Sheet`](frontend/src/components/v3/generic/Sheet/Sheet.tsx)                   | Panel for larger forms or workspaces; right is the usual side. Choose `size=\"form\"`, `\"wide\"`, or `\"workspace\"` by content, using the [size examples](frontend/src/components/v3/generic/Sheet/Sheet.stories.tsx); other sides serve specific flows. |\n| [`Dialog`](frontend/src/components/v3/generic/Dialog/Dialog.tsx)                | Centered modal — **use for small create/edit forms** (1–2 fields, single confirmation prompt) and short interactive prompts. |\n| [`AlertDialog`](frontend/src/components/v3/generic/AlertDialog/AlertDialog.tsx) | Confirm an action (destructive included). Replaces `confirm()`.                                                              |\n| [`Popover`](frontend/src/components/v3/generic/Popover/Popover.tsx)             | Anchored floating panel — filters, pickers, contextual UI.                                                                   |\n| [`Tooltip`](frontend/src/components/v3/generic/Tooltip/Tooltip.tsx)             | Small floating annotation on hover/focus.                                                                                    |\n| [`Accordion`](frontend/src/components/v3/generic/Accordion/Accordion.tsx)       | Collapsible sections.                                                                                                        |\n\n#### Data display\n\n| Component                                                                    | Reach for this when…                                                                                                  |\n| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| [`Table`](frontend/src/components/v3/generic/Table/Table.tsx)                | Read-mostly list of records with sortable columns. Pair with `Empty` + `Pagination`.                                  |\n| [`DataGrid`](frontend/src/components/v3/generic/DataGrid/data-grid.tsx)      | Editable spreadsheet-style grid — copy/paste, multi-cell selection, keyboard nav. Use only when `Table` isn't enough. |\n| [`Pagination`](frontend/src/components/v3/generic/Pagination/Pagination.tsx) | Page controls under a Table or list.                                                                                  |\n| [`Item`](frontend/src/components/v3/generic/Item/Item.tsx)                   | Vertically-stacked list rows with shared spacing — when a `Table` is too heavy.                                       |\n| [`Detail`](frontend/src/components/v3/generic/Detail/Detail.tsx)             | Read-only label/value pairs in a detail view.                                                                         |\n| [`Badge`](frontend/src/components/v3/generic/Badge/Badge.tsx)                | Small label or chip — status, scope tag, key/value pair.                                                              |\n| [`SelectedActionBar`](frontend/src/components/v3/generic/SelectedActionBar/SelectedActionBar.tsx) | Floating batch actions for a selected set of records. |\n\n#### Navigation & search\n\n| Component                                                                    | Reach for this when…                              |\n| ---------------------------------------------------------------------------- | ------------------------------------------------- |\n| [`Sidebar`](frontend/src/components/v3/generic/Sidebar/Sidebar.tsx)          | Scope-aware product navigation panel.             |\n| [`Breadcrumb`](frontend/src/components/v3/generic/Breadcrumb/Breadcrumb.tsx) | Hierarchical location trail at the top of a page. |\n| [`Command`](frontend/src/components/v3/generic/Command/Command.tsx)          | Search-driven command palette / typeahead list.   |\n| [`Tabs`](frontend/src/components/v3/generic/Tabs/Tabs.tsx)                   | Switch between related views in the same context. |\n| [`Stepper`](frontend/src/components/v3/generic/Stepper/Stepper.tsx)          | Show progress through a staged workflow.          |\n\n#### Feedback & loading\n\n| Component                                                                     | Reach for this when…                                             |\n| ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |\n| [`Alert`](frontend/src/components/v3/generic/Alert/Alert.tsx)                 | Inline message banner inside a page or Card.                     |\n| [`Toast`](frontend/src/components/v3/generic/Toast/Toast.tsx)                 | Transient post-action feedback. Replaces `alert()`.              |\n| [`Empty`](frontend/src/components/v3/generic/Empty/Empty.tsx)                 | Zero-state placeholder — pair with Table, list, or empty filter. |\n| [`Skeleton`](frontend/src/components/v3/generic/Skeleton/Skeleton.tsx)        | Shimmer placeholder while data is loading.                       |\n| [`Spinner`](frontend/src/components/v3/generic/Spinner/Spinner.tsx)           | Project-accented spinner by default; choose `xs`/`sm` for compact waits. |\n| [`Loader`](frontend/src/components/v3/generic/Loader/Loader.tsx)              | Branded loading animation — pending controls and page waits.     |\n| [`PageLoader`](frontend/src/components/v3/platform/PageLoader/PageLoader.tsx) | Centered Lottie spinner for full-page loading.                   |\n\n**Reduced motion.** When the operating system requests reduced motion, `Loader`\nand `PageLoader` hold the animation on its first frame instead of looping. The\nmark stays fully visible either way, because these Lotties animate only a\ntrim-path offset over an always-drawn mark. `role=\"status\"` is present in both\nstates, so the wait is still announced.\n\n#### Atoms & domain\n\n| Component                                                                                                         | Reach for this when…                                                                  |\n| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| [`Separator`](frontend/src/components/v3/generic/Separator/Separator.tsx)                                         | Horizontal/vertical divider.                                                          |\n| [`AccessRestrictedNotice`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedNotice.tsx) | Inline restriction for a section. |\n| [`AccessRestrictedDialog`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedDialog.tsx) | Page/tab access gate rendered in document flow, despite its name. |\n| [`ScopeIcons`](frontend/src/components/v3/platform/ScopeIcons.tsx)                                                | `OrgIcon` / `SubOrgIcon` / `ProjectIcon` / `InstanceIcon` — use when intent is scope. |\n| [`SecretManagerResources`](frontend/src/components/v3/platform/SecretManagerResources/SecretManagerResources.ts) | Canonical Secret Manager resource catalog (icon, color classes, name, slug, permission subject). |\n| [`ProjectPermissionSubjects`](frontend/src/components/v3/platform/ProjectPermissionSubjects/ProjectPermissionSubjects.ts) | Icon and tile color for every project policy subject (`ProjectPermissionSub`). |\n| [`PageHeader`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx)                                     | Canonical full-width page heading with scope semantics, description, and page actions. |\n| [`DocumentationLinkBadge`](frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.tsx) | Inline \"Documentation\" link badge in `CardTitle`.                                      |\n\n**Icons** — [`lucide-react`](https://lucide.dev). Sizing is bound by the\nhost component; don't override unless necessary.\n\n## 5. Layout Principles\n\n- **Page container** — `max-w-8xl` (88rem) centered, `bg-page`.\n- **Page header** — v3 `PageHeader` with scope/product icon + underlined `h1` + description. Import it from `@app/components/v3` and set `scope` to `org`, `namespace`, `instance`, or the applicable `ProjectType`. See [`PageHeader.tsx`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx).\n- **Header boundary** — `PageHeader` owns the normal 40px bottom margin (`mb-10`). Do not repeat that margin at ordinary callers or add a parent row gap between the header and body. Keep independent card/section spacing in the body layout. Use `className` for genuine compact/responsive cases, such as `mb-6 md:mb-10` for Overview; see the header's spacing stories.\n- **Section** — one `Card` per logical section. Title + optional `DocumentationLinkBadge` in `CardHeader`; primary action in `CardAction` (top-right).\n- **Tables inside Cards** — filters and search sit in the `CardHeader` above the table; pagination sits in the `CardFooter` or bottom of `CardContent`. **Empty state** — when the table has no rows (and isn't loading), hide the `Table` entirely and render `Empty` in its place; never leave a column header floating above a blank body. Add `className=\"border\"` to `Empty` whenever it's nested in a `Card`, `Sheet`, or `Dialog` so the dashed frame is visible against the parent surface (the component ships dashed-but-borderless on purpose for page-level use).\n- **Forms inside Sheets/Dialog** — create / edit flows open in a Sheet or Dialog, never inline, never as a full-page route. **Pick by form size:** small forms (1–2 fields, e.g. \"Add domain\", \"Rename\") go in a centered `Dialog`; large or multi-step forms (multiple fields, scrollable detail, file uploads, wizard steps) go in a right-side `Sheet`. When in doubt, default to Dialog — Sheet is for cases where Dialog feels cramped.\n- **Authentication form stacks** — use `CardContent className=\"flex flex-col gap-4\"` for the major vertical rhythm between fields, validation groups, and primary actions. Use a nested `flex flex-col gap-2` only for compact sets of equivalent actions such as social or SSO provider buttons. Use `FieldSeparator` between alternate authentication paths. Do not recreate sibling spacing with `mt-*`, `mb-*`, or `py-*`; keep a wrapper only when it owns animation, responsive visibility, conditional grouping, or tightly coupled control/error content.\n- **Spacing rhythm** — `gap-1.5` (intra-element), `gap-2 / gap-3` (adjacent elements), `p-4 / p-5` (section padding). Card = `p-5 gap-5`; Sheet header/footer = `p-4`.\n- **Sheet and Dialog footers** — actions sit right-aligned with the primary action last: `SheetFooter className=\"justify-end border-t\"` (`DialogFooter` is right-aligned by default), then Cancel (`ghost`), then the primary button. Back navigation in a multi-step flow sits on the left with `mr-auto`, and wizard footers with status text on the left use `justify-between`. A single full-width action needs no alignment class.\n\n## 6. Depth & Elevation\n\nDepth is conveyed primarily by layered surface tones and borders. The shared\nv3 `Card` includes `shadow-xs` by default; stronger shadows are reserved for\nelements that float (Popover, DropdownMenu, Sheet, SelectedActionBar).\n\n| Layer           | Surface                                  | Border                              |\n| --------------- | ---------------------------------------- | ----------------------------------- |\n| Page            | `bg-background`                          | —                                   |\n| Card            | `bg-card`                                | `border-border`                     |\n| Popover / Dropdown main menu | `bg-popover`               | `border-border` + `shadow-md`       |\n| Dropdown submenu | `bg-popover`                           | `border-border` + `shadow-lg`       |\n| Sheet           | `bg-popover`                             | `border-border` + `shadow-lg`       |\n| Floating bar    | `bg-popover`                             | `border-border` + `shadow-floating` |\n| Row hover       | `bg-container-hover`                     | —                                   |\n| Focus           | —                                        | Component-owned focus-visible treatment (Button/IconButton use `ring-2`; default Input uses `ring-[3px]`) |\n| Disabled        | `opacity-50 / 75`, `pointer-events-none` | —                                   |\n\nDo not add a shadow to a Card beyond its shared `shadow-xs` default, or to a\nTable row, standalone Badge, or `ButtonBadge`. An overlapping `ButtonBadge`\nuses its solidified semantic tint—not custom content styling or elevation—to\nremain legible across the control edge.\n\n## 7. Do's and Don'ts\n\n- **DO** choose Badge and Button variants by **intent** (danger / success /\n  warning / info / neutral), not by color preference.\n- **DO** use scope colors (`org`, `sub-org`, `project`, `admin`) to reinforce\n  hierarchy, and product colors (`pam`, `av`) for their own product surfaces.\n- **DO** mask secret values by default. Reveal must be an explicit user\n  action and should be logged.\n- **DO** put large create / edit forms in a right-side Sheet; smaller forms can be in Dialogs.\n- **DO** pair destructive confirmations with the resource name and the\n  consequence (see §9).\n- **DO** cite tokens (`bg-card`) over hex (`#xxxxxx`) in new code.\n- **DON'T** replace existing blocked v2 consumers based on matching component names alone; check the lifecycle ledger and interaction parity first.\n- **DON'T** add ad hoc box-shadows as a depth cue — borders and surface tones do\n  that work. The shared `Card` already has `shadow-xs`; floating elements\n  (Popover, DropdownMenu, Sheet, SelectedActionBar) include stronger shadows.\n- **DON'T** invent new colors. If it isn't in `index.css` `@theme`, it\n  doesn't belong.\n- **DON'T** use `project` yellow, `org` blue, or `sub-org` green as generic\n  accents. They are scope signals; repurposing them creates false hierarchy.\n  The documented brand-forward `text-highlight` treatment and deliberately\n  branded `ButtonBadge` flags are narrow exceptions for authentication and\n  onboarding surfaces.\n- **DON'T** mix font families arbitrarily. Use IBM Plex Sans for UI and body\n  text, Alliance Regular for headings, and JetBrains Mono (`font-mono`) for\n  monospaced content.\n- **DON'T** animate for decoration. Motion should clarify state change only.\n\n## 8. Voice & Content Tone\n\nCopy should read as if written by an engineer for another engineer: direct,\ntechnical, specific. The domain is serious — secrets, access, compliance —\nand the voice reflects that.\n\n### Stance\n\n- Direct. Active voice. Lead with the subject: \"Delete this role\" — not\n  \"This role will be deleted\".\n- Specific. Name the resource, the action, the consequence. Avoid vague\n  verbs (\"handle\", \"manage\") when a precise verb exists (`rotate`, `revoke`,\n  `import`).\n- Calm. No exclamation marks. No second-person cheer (\"Awesome!\",\n  \"You're all set!\"). No emoji.\n- Honest. Never claim speed, power, or ease in UI copy (\"seamless\",\n  \"powerful\", \"blazing-fast\"). Those belong on the marketing site, not here.\n\n### Shapes\n\n- **Labels & buttons** — Title Case, imperative: \"Add Secret\", \"Revoke\n  Access\", \"Rotate Key\".\n- **Title Case applies to** — buttons, badges, dropdown menu items, card\n  titles, dialog titles, sheet titles, page titles, tab labels, table column\n  headers. Anything that names a thing or an action is Title Case.\n- **Descriptions & helper text** — sentence case, one short sentence.\n- **Empty states** — state what's missing, then the next action:\n  \"No secrets yet. Add your first secret to get started.\"\n- **Errors** — name the failure and the remedy. Never \"Something went wrong\":\n  \"Could not rotate secret — token lacks `secrets:write` permission.\"\n- **Destructive confirmation** — name the resource and the consequence \"Delete \"API_KEY\" — this cannot be undone.\"\n- **Success toasts** — past tense, specific: \"Secret \"API_KEY\" created\".\n\n### Secrets & sensitive values\n\nNever include a secret's value in any user-visible copy — UI, logs, toasts,\nerrors, audit trails, or analytics. Refer to secrets by key only. Mask\ntokens and keys in screenshots and docs as well.\n\n### Documentation links\n\nUse `DocumentationLinkBadge` (info variant, external-link icon). Label it\n\"Documentation\" — not \"Learn more\", \"Read docs\", \"See more\".\n\n## 9. Agent Prompt Guide\n\nPasteable prompt fragments for AI coding agents producing new UI.\n\n**Before generating UI for any component:**\n\n1. Find the export in the v3 generic or platform barrel and read its source.\n2. If a sibling story exists, consult its examples and use-when guidance.\n3. Pick a supported variant by intent and current scope/product, not color preference.\n\n**Adding a section to an existing page:**\n\n> Wrap the section in a `Card` from `@app/components/v3`. Use `CardHeader`\n> with `CardTitle` + optional `CardDescription` + `CardAction` for the\n> top-right primary button (the variant for the current scope or product,\n> when supported). Put the table or content in `CardContent`.\n\n**A new create/edit form:**\n\n> Pick the container by form size. **Small forms (1–2 fields, e.g. \"Add\n> domain\", \"Rename\"):** centered `Dialog` (`Dialog`, `DialogContent`,\n> `DialogHeader` with `DialogTitle` + `DialogDescription`, `DialogFooter`\n> with the action buttons). **Large or multi-step forms (many fields,\n> scrollable detail, wizards):** right-side `Sheet` (`Sheet`, `SheetContent`,\n> `SheetHeader` with `SheetTitle` + `SheetDescription`, `SheetFooter` with\n> the action buttons). Use `react-hook-form` with a Zod resolver in both\n> cases. Compose labeled/validated inputs in `Field` with `FieldLabel` and\n> only the description, content layout, or feedback slots needed. Primary\n> button variant follows the current scope/product (`project`, `org`,\n> `sub-org`, `pam`, or `av`, when supported); cancel is `ghost`.\n\n**A status indicator:**\n\n> Use `Badge` from `@app/components/v3`. Pick the variant by intent:\n> `danger` for errors or expired access, `warning` for stale or\n> attention-warranting, `success` for healthy / completed, `info` for\n> informational, `neutral` for disabled / empty, `project` / `org` /\n> `sub-org` for scope references. Include a matching Lucide icon as the\n> first child.\n\n**A destructive confirmation:**\n\n> Use `AlertDialog`. Title: \"Delete `<resource-name>`\". Description: one\n> sentence naming the consequence, ending with \"This cannot be undone.\"\n> Confirm button is variant `danger`. Cancel button is variant `outline`.\n\n**Unsaved changes:**\n\n> Use `DiscardChangesAlertDialog` with `useDiscardChangesGuard`. Title:\n> \"Discard Changes?\". Description names what will be lost. Confirm is\n> Discard (`danger`); cancel is Keep Editing. Overlay editors intercept\n> close with `requestDiscard`. Do not use `window.confirm`.\n\n**A documentation link in a section:**\n\n> Use `DocumentationLinkBadge` from `@app/components/v3/platform`. Place it\n> in the `CardTitle` next to the section name.\n\n**Refer to:**\n\n- [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx) — canonical semantic reference for variant choice.\n- [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) — full-page composition reference (v3 PageHeader, Card-with-table, Create Secret Sheet, filters, DropdownMenu + ButtonGroup).\n- [`AccessControlPage`](frontend/src/pages/project/AccessControlPage) — full-page reference (permission-gated actions, `DocumentationLinkBadge`, role badges with `ClockAlertIcon` for expired access).\n- §8 above for any user-visible copy.\n\n## Appendix: Iteration Guide\n\n1. **Run Storybook** — `cd frontend && npm run storybook` (port 6006). Open\n   Badge, Button, Card, Table, Sheet first.\n2. **Read the two reference pages** — `OverviewPage` and `AccessControlPage`\n   render the full v3 vocabulary in production.\n3. **Tokens live in `index.css`** — `@theme` blocks and theme overrides. Never\n   introduce a hex that is not here.\n4. **Adding a variant** — extend the `cva()` block in the component and add\n   a story when appropriate. Match the existing variant family rather than\n   imposing a tint recipe on neutral, outline, or product variants.\n5. **Prefer v3 for new code** — use v2 only where needed for behavior not covered\n   by v3. Existing blocked v2 consumers migrate after lifecycle parity review.\n6. **Before merging** — `make reviewable-ui` (lint + type-check).\n7. **When in doubt** — mirror `OverviewPage`.\n",".agents/skills/docs-style/templates/README.md":"# Docs page templates\n\nEach template gives the section order, components, and fixed wording for one type of page.\n\n| Template | Use for | Lives at |\n| --- | --- | --- |\n| [app-connection.mdx](app-connection.mdx) | An app connection guide | `docs/integrations/app-connections/{product}.mdx` |\n| [secret-sync.mdx](secret-sync.mdx) | A secret sync guide | `docs/integrations/secret-syncs/{product}.mdx` |\n| [secret-rotation.mdx](secret-rotation.mdx) | A secret rotation guide | `docs/documentation/platform/secret-rotation/{rotation}.mdx` |\n| [quickstart.mdx](quickstart.mdx) | A quickstart or task guide that ends in a working result | Varies |\n| [landing-page.mdx](landing-page.mdx) | The overview page at the top of a docs section | `{section}/overview.mdx` |\n| [concept-page.mdx](concept-page.mdx) | A page about one concept and the tasks that involve it | Varies |\n\n## How to read a template\n\nEvery template uses the same four kinds of markup:\n\n| Markup | Meaning | What to do |\n| --- | --- | --- |\n| Plain text | Fixed wording | Keep the wording if the wording is true for this product. Check every claim in it against the product first |\n| `{Description}` | A placeholder | Replace the whole placeholder with your own text. The placeholder describes what goes there; it never contains text to copy |\n| `{/* IF: condition */}` ... `{/* END IF */}` | A conditional block | Keep the block, without the two markers, only if the condition is true. Otherwise delete the block |\n| `{/* NOTE: ... */}` | Guidance for the writer | Follow the guidance, then delete the comment |\n\nBraces inside a code span, such as `` `{{secretKey}}` ``, are literal text the product uses, not\nplaceholders.\n\nBefore you publish, delete every `{/* ... */}` comment and search the page for `{` to find any\nplaceholder you missed.\n\n## How closely to follow a template\n\nA template is the default structure for a page type, not a form to fill in:\n\n- **Leave out what the product doesn't have.** If a sync has no destination fields, or a\n  quickstart has no second sub-task, delete that part rather than writing filler for it\n- **Add what the product needs.** If the product needs a step or a section the template doesn't\n  have, add it where a reader would look for it (STYLE_GUIDE.md section 7)\n- **Check the fixed wording.** Fixed wording is true for most products of the type, not all of\n  them. Button labels, field names, and behavior claims such as \"The name must be\n  slug-friendly\" come from one version of the product, so check each one against the frontend\n  or backend before keeping it\n- **Write your own sentences.** The templates contain no example sentences on purpose. For what\n  a finished page looks like, read the example pages named at the top of each template, but\n  don't copy their sentences, which are about a different product\n\n## Shared snippets for an integration\n\nAn app connection page, a sync page, and a rotation page for the same product render the same\nsetup steps and form fields, so those live in snippets under\n`docs/snippets/app-connections/{product}/`:\n\n- `form-fields.mdx`: a bullet list of the connection form's fields, starting with **Name** and\n  **Description** (optional)\n- `{setup}.mdx`, named after the setup task in the third-party product: a `<Steps>` block for\n  that setup, with screenshots\n- `{setup}-for-rotation.mdx`: only when a rotation needs more setup than the connection does;\n  import the base snippet and add the extra steps rather than copying the base steps\n\nA link in `form-fields.mdx` to a step in the setup snippet resolves on whichever page renders\nboth snippets. Link to the `## Step 1:` heading that every page puts the setup snippet under,\nand give that heading the same text on the connection, sync, and rotation pages. Linking to a\nstep title doesn't work, because steps don't have a `title` prop.\n\nLinks from a sync or rotation page to the organization-level connection steps use\n`/integrations/app-connections/{product}#step-2-create-the-app-connection`.\n\n## Steps that depend on the integration\n\nWhen a connection needs different setup for each integration that uses it, such as a separate\npermission policy per integration, put a use case picker at the top of the page instead of a tab\nper integration. `snippets/UseCasePicker.jsx` exports two components:\n\n- `<UseCasePicker prefix=\"I want my {Product} connection to work for\" groups={...} />`: a\n  sentence ending in a dropdown where the reader picks one or more integrations\n- `<UseCase use=\"{id}\">`: shows its children only when the reader picked that id, or when they\n  picked nothing, so the page reads as the complete guide by default. `use` takes a\n  comma-separated list for content that several integrations share. Add `hideWhenEmpty` for\n  content that only makes sense next to a filtered result, such as setup notes that follow a\n  combined policy, so it stays hidden until the reader picks something\n\nA `<UseCase>` can hold its own `###` heading. Mintlify builds the \"On this page\" list at build\ntime, so it would still list hidden headings; the picker hides those entries at runtime, so the\nlist matches what's on the page\n\nPut the options in `snippets/app-connections/{product}/use-cases.jsx` as an exported array of\n`{ label, options: [{ id, label }] }` groups, import it on the page next to the component, and\npass it as `groups`. A snippet can't import another snippet, so the page does both imports. The\npicker writes the selection to the URL (`?use=id-1,id-2`), so a reader can share a link to a\nfiltered page.\n\nWrap each integration's content in its own `<UseCase>`, including its card in the\n\"Configure integrations\" step. `integrations/app-connections/aws.mdx` is the example.\n\n## Where the example pages differ from the templates\n\nThe example pages each template names predate some of the style guide's rules. When you read\nan example page, don't copy these:\n\n- Titled steps (`<Step title=\"...\" titleSize=\"h3\">`); the guide says to leave out `title`\n- Periods at the end of bullets\n- Em dashes, such as \"This guide doesn't configure any behavior—it just authenticates\"\n- Button labels in the 1Password and Databricks sync and rotation pages, which come from older\n  versions of the wizards (**Next** instead of **Continue**, **Disable Secret Deletion**\n  instead of **Prevent secret deletion**)\n",".agents/skills/docs-style/SKILL.md":"---\nname: docs-style\ndescription: Write, edit, and review Infisical documentation to the house style guide. Use whenever creating or changing any file under docs/ (.mdx pages, snippets, docs.json navigation), when asked to document a feature, write or update a guide, or review a docs diff or pull request. Covers running the Vale linter and the sentence-level review that Vale can't do.\n---\n\n# Infisical docs style\n\n`docs/STYLE_GUIDE.md` holds every rule for Infisical documentation. This skill doesn't repeat\nthe rules. This skill describes how to apply them.\n\n**Before you write or review anything, read all of `docs/STYLE_GUIDE.md` with the Read tool.**\nDon't work from a summary, a diff, or your memory of the guide. Each rule in the guide comes\nwith an \"Instead of\" and \"Write\" example, and the examples show what the rule means better\nthan any summary can.\n\n## Templates\n\nTo check if the page you're documenting has a template to follow, start from the matching file in\n[templates/](templates/README.md). The README says which template fits and how the snippets\nshared between integration pages work.\n\n## Before you write\n\nFind the facts yourself, in the code and the running product, before you draft anything. Don't\nask a question that the code can answer. Ask only about what the code can't tell you, such as\nwhy a feature exists or who it's for.\n\nCheck every fact against the product, not against other docs pages, because other docs pages\nare often out of date:\n\n- **UI steps:** Take button, tab, and field names and the order of screens from the running\n  product, or from the frontend source if you can't reach the running product\n- **Behavior claims:** Read the code path before you describe what the product does; if\n  another docs page seems to contradict the code, find out why before you edit either page\n\n## Reviewing\n\nReview your own drafts the same way you review anyone else's. Work in the following order:\ncut first, then check the structure, then check the sentences, then review the edited page\nagain. Rewriting a sentence is wasted work if the sentence shouldn't be on the page, or if it's\nin the wrong section.\n\n**1. Run the linter.** Run `make lint-docs-branch` from the repository root. Every rule except\n`Infisical.EmDashes` reports at error level, so any other finding fails the command, including\n`Infisical.UIActions` and `Infisical.Contractions`, the two rules most likely to report problems.\n`Infisical.EmDashes` reports at warning level and doesn't change the exit code, so also read the\nwarnings the command prints.\n\nVale doesn't check text indented four or more spaces inside a Mintlify component, and about\nhalf of the text in this repository is indented that way. If Vale reports no problems on a\npage with nested components, the nested text still needs the checks in steps 2, 4, and 5.\n\n**2. Cut what the reader won't act on.** Read the page one paragraph at a time. For each fact,\nask what the reader would do differently after reading it, as \"Leave out what the reader won't\nact on\" in section 2 of the guide describes. If the answer is nothing, the fact is a finding,\neven if it's true and you checked it in the code.\n\nNone of the other steps catches this. A sentence that shouldn't be on the page can still pass\nevery rule in section 5, and if you restructure it instead of cutting it, the page gets more\nheadings and lists without getting any shorter.\n\nApply this step to everything on the page: content that was already there, content someone else\nwrote, and your own draft. Treat each cut like any other finding.\n\n**3. Check the structure against sections 1, 4, 6, 7, 8, and 10 of the guide.** Check every\nsection of the page against each of those sections of the guide, including the page sections\nthat look fine at first glance.\n\n**4. Check every sentence against sections 3 and 5 of the guide.** Go through the sections one\n`###` heading at a time. Each heading names one rule, so for each heading, read every sentence\non the page and check whether the sentence breaks that rule. Then read every sentence out loud,\nas section 5 describes.\n\n**5. Check the formatting against section 11 of the guide.** No automated check covers any\nrule in section 11, so check each rule by reading the page.\n\nBefore you hand a draft back, check the draft for every mistake you've already been corrected\non, including corrections earlier in the same conversation.\n\n**6. Apply the findings.** Edit the page to fix every finding. Don't commit anything.\n\nIf the person you're working with wrote or edited the prose themselves in this conversation,\ndon't change their sentences yet. Report those findings in the format in step 8, and ask once\nwhether to apply them.\n\n**7. Review the edited page.** Run steps 1 to 5 again on the whole edited page, from the top.\nYour own edits create new problems. For example, turning part of a paragraph into a list can\nleave several short paragraphs in a row, and moving content can put a section out of order.\nFix what you find before you hand the page back.\n\n**8. Report what you changed.** Write one change per line: the location as\n`path/to/file.mdx:12`, the rule the change fixes, the text before the change, and the text\nafter it, or \"cut\" for a finding from step 2. Group the changes by file, and include the fixes\nfrom step 7.\n\n## When Vale is wrong\n\n`docs/CONTRIBUTING.MD` describes how to add a word to the vocabulary, enforce a spelling, and\nturn off a rule for one line with `{/* vale Infisical.RuleName = NO */}`. Turn off a rule only\non a specific line where Vale is actually wrong, never to hide problems across a whole page.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"## Feature Development\n\nCheck out CLAUDE.md for additional context on project file structure and general feature development.\n\n## Backend Development\n\n**Read the [Backend Code Quality Guide](backend/CODE_QUALITY.md) for any change under `backend/`, and check your work against it before reporting the task done.** Features, refactors, bug fixes, and reviews all count.\n\nIt is a short, deliberately non-exhaustive floor: error messages users can understand (and no pointless 500s), validation on every API input, correct pagination when calling third-party APIs, no deadlock conditions on a small connection pool, REST-aligned API interfaces, and audit log events an admin can actually read.\n\nTreat that list as a summary of the guide's current contents, not as a condition for reading it. A change that does not look like any of those topics still gets checked, because the guide grows and because the items apply in places they are not obviously about (a bug fix that adds a query inside an existing transaction, a refactor that moves a third-party list call).\n\nSome of it needs judgment rather than a mechanical check. The deadlock rules cannot be caught by testing one request at a time. And a design that breaks REST should be raised with the author, with the conforming alternative proposed, rather than implemented silently or quietly \"fixed\" (some deviations are deliberate).\n\n## Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md), including the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/` (or `make lint-docs` for the whole site; the `Check docs style` CI check runs the branch variant). It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide. A clean run is not a substitute for reading the guide: the judgment calls it cannot check are the ones that matter most. Vale cannot see prose indented inside Mintlify components, so a clean run is not evidence that nested content was checked, and `Infisical.EmDashes` reports at warning level, so its findings never change the exit code.\n\n## UI Development\n\nWhen building frontend UI, follow [DESIGN.md](DESIGN.md) for the v3 design system — colors, typography, components, and voice.\n\nBefore changing a shared frontend component's lifecycle, read the [shared component lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) for current deprecations, blockers, and replacement decisions.\n\nPickers for entities a user may need to create first (app connections, gateways, and similar) must offer an inline \"Create new X\" row, gated on the create permission, so the user never has to leave the flow. Build them with the v3 `Combobox` and its `creation` prop, which renders that row; `Combobox` replaces both the deprecated `FilterableSelect` and `CreatableSelect`. For app connections, use `creation.mode = \"dialog\"` and have `renderForm` render `AppConnectionForm` with the app preset, selecting the new connection and calling `complete` in its `onComplete`, and route `onDismiss` and the form's cancel through `useDiscardChangesGuard` (see `CaInstallCertModal/useConnectionCreation.tsx`). A v2 `Modal` parent sits at `z-[60]`, above v3 overlays, so migrate it to the v3 `Dialog` when it hosts one (see `CaInstallCertModal/CaInstallCertModal.tsx`).\n\n## Issue and PR Guidelines\n\n- Never create a GitHub issue.\n- When creating a pull request, use and fully complete the repository's\n  `.github/pull_request_template.md` template.\n\n## Workflow Guardrails\n\n- Do not reformat existing code as incidental cleanup. Preserve the repository's current formatting unless a review agent, CI check, or user explicitly asks for a formatting change.\n- Never push changes to `license-fns` or local UI fixtures to a remote branch. Those edits are local-only support for review and test passes; keep them out of commits and pull requests.\n- When resolving merge conflicts, optimize for a correct, minimal, strategically unblocking resolution. Do not let a slow or stalled local check block the push when the corresponding frontend or backend CI checks on GitHub can validate the branch; push the resolved code and use CI to complete the expensive verification.\n","isInternal":false,"tokens":1028,"sizeBytes":4744},{"name":"DESIGN.md","path":"DESIGN.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/DESIGN.md","title":"UI Design System & Style Tokens","category":"project-spec","format":"markdown","content":"# Infisical Design System (v3)\n\nThis document captures the v3 visual language and product voice used across\nInfisical. It is the single reference for engineers, designers, and AI coding\nagents producing new UI or user-visible copy.\n\n**Source of truth for tokens and themes:** [`frontend/src/index.css`](frontend/src/index.css) (`@theme` blocks and theme overrides).\n**Canonical semantic reference:** [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx).\n**Canonical page references:** [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) and [`AccessControlPage`](frontend/src/pages/project/AccessControlPage).\n**Component usage reference:** Start with the sibling story in [`v3/generic`](frontend/src/components/v3/generic) or [`v3/platform`](frontend/src/components/v3/platform) when one exists, then check source and consumers. Some exports, including `CopyButton`, `DataGrid`, and `Sidebar`, have no sibling story; the [generic](frontend/src/components/v3/generic/index.ts) and [platform](frontend/src/components/v3/platform/index.ts) barrels are the complete export catalogs.\n\n---\n\n## 1. Visual Theme & Atmosphere\n\nInfisical is a security tool for operators. The interface reads like\ninfrastructure: dense, calm, and legible. Decorative treatments are reserved\nfor brand-forward surfaces such as authentication and onboarding; the core\nproduct remains utilitarian. Dark is the default medium; the page canvas is\n`--color-page`. Light mode is implemented through semantic token overrides and\nthe theme provider (with intentionally forced-dark authentication paths).\nCheck shared surfaces in both themes rather than hard-coding dark contrast.\n\nColor carries **meaning before brand**. A danger badge is red because the\naction is destructive, not because red is the accent. A project-colored button\nsignals project scope, not visual variety. Designers pick intent; hex values\nfollow.\n\nDepth is drawn primarily with borders and surface tones; the shared v3 `Card`\nadds a subtle `shadow-xs`. Motion is restrained and clarifies state changes;\ncontrol and collapse transitions often use 200ms, while overlays own their\nentry/exit timing. Respect reduced-motion behavior rather than assuming all\nanimations share one implementation. Secret values are masked by default;\nrevealing one is an intentional act.\n\n**Key characteristics:**\n\n- Dark default with light semantic overrides; `--color-page` page canvas\n- Semantic-first color (danger / success / warning / info / neutral)\n- Scope- and product-aware (org / sub-org / project / admin / PAM / Agent Vault)\n- Border-first depth with the shared `Card`'s `shadow-xs`; overlapping labels\n  retain the shared Badge styling\n- IBM Plex Sans for product UI, Alliance No. 2 for headings, and JetBrains\n  Mono for monospaced content\n- Secrets masked by default; reveal is an act\n\n## 2. Color Palette & Roles\n\nAll colors are defined as CSS custom properties in\n[`frontend/src/index.css`](frontend/src/index.css) and consumed via Tailwind v4\nutilities (`bg-org`, `text-danger`, etc.). Never introduce a hex that is not\nin this file.\n\n### Scope colors (hierarchy)\n\nUsed to signal the scope a surface, badge, or action belongs to.\n\n| Scope            | Token             |\n| ---------------- | ----------------- |\n| Organization     | `--color-org`     |\n| Sub-Organization | `--color-sub-org` |\n| Project          | `--color-project` |\n| Admin            | `--color-admin`   |\n\nPAM, Agent Vault and Secret Scanning are product identities rather than\nadditional levels in the org/project hierarchy. Use `--color-product-pam`,\n`--color-product-av` and `--color-product-ss` when the current product calls\nfor them; `PageHeader` and the org sidebar map those project types to these\naccents, while `Button` exposes `pam`, `av` and `ss` variants. Check each component's supported variants instead of treating all\nscope and product variants as interchangeable.\n\n### Semantic colors\n\n| Intent  | Token             | Use                                          |\n| ------- | ----------------- | -------------------------------------------- |\n| Success | `--color-success` | Healthy states, completed rotations          |\n| Info    | `--color-info`    | Informational states, external documentation |\n| Warning | `--color-warning` | Attention-warranting states, stale items     |\n| Danger  | `--color-danger`  | Destructive actions, errors, expired access  |\n| Neutral | `--color-neutral` | Disabled, muted, \"empty\" states              |\n\n### Surface & chrome\n\n| Role              | Token                     |\n| ----------------- | ------------------------- |\n| Page canvas       | `--color-page`            |\n| Background        | `--color-background`      |\n| Foreground text   | `--color-foreground`      |\n| Card surface      | `--color-card`            |\n| Popover / Sheet   | `--color-popover`         |\n| Container         | `--color-container`       |\n| Container (hover) | `--color-container-hover` |\n| Border            | `--color-border`          |\n| Focus ring        | `--color-ring`            |\n| Accent text       | `--color-accent`          |\n| Muted text        | `--color-muted`           |\n| Label text        | `--color-label`           |\n\nThe `mineshaft-*` scale (50–900) is the underlying neutral ramp; see\n`index.css` for the full list. Prefer semantic tokens (`card`, `border`,\n`accent`) over raw mineshaft values.\n\n### Product-area accents (secret-manager)\n\nReserved for resource types in the secret management product:\n`--color-folder`, `--color-secret`, `--color-dynamic-secret`,\n`--color-import`, `--color-secret-rotation`, `--color-override`,\n`--color-proxied-service`.\nDo not repurpose these for generic UI.\n\n### Variant treatments\n\nChoose the component variant by semantic intent or actual scope/product, not\nby a preferred hue. Named semantic and scope tints commonly use these recipes:\n\n- **Badge** — `bg-<c>/15 border-<c>/10 text-<c>`, hover `bg-<c>/35`\n  (see [`Badge.tsx`](frontend/src/components/v3/generic/Badge/Badge.tsx))\n- **Button** — `bg-<c>/10 border-<c>/25 text-foreground`, hover `bg-<c>/15 border-<c>/30`\n  (see [`Button.tsx`](frontend/src/components/v3/generic/Button/Button.tsx))\n\n`Button` product variants (`pam`, `av`, `product`) use `/25` backgrounds and\n`/30` borders. Neutral/default, outline, ghost, shaded, and link treatments\nhave their own contracts; the tint recipes are not universal. Read the source\nfor exact classes and supported variants.\n\n## 3. Typography\n\nTypography uses three intentional families:\n\n- **IBM Plex Sans** (`font-sans`, `--font-sans`) is the default product UI face.\n  Body copy, forms, tables, buttons, labels, and navigation inherit it. Real\n  400, 500, 600, and 700 weights are bundled for UI hierarchy.\n- **Alliance No. 2** (`font-alliance`, `--font-alliance`) is the heading face.\n  Semantic headings and shared page, card, dialog, alert dialog, and sheet\n  titles use its bundled Regular weight. Do not use it for arbitrary bold body\n  text or request synthetic heavier weights.\n  Product and project tile names are titles, not descriptions. Use `CardTitle`\n  for them. Do not override semantic headings or shared titles with medium,\n  semibold, or bold weights; express hierarchy with size and spacing instead.\n- **JetBrains Mono** (`font-mono`, `--font-mono`) is the functional and\n  decorative mono. Use it for code, secret values, identifiers, timestamps,\n  logs, and short technical microcopy.\n\n| Role                    | Class                                                                     | Notes                                                              |\n| ----------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| Page title (h1)         | `font-alliance text-2xl font-normal underline underline-offset-4 decoration-<scope>/90` | In `PageHeader`; scope icon (size 26) sits inline before the title |\n| Page description        | `text-label`                                                              | Sits under the title, separated by the header's `gap-2`            |\n| Card title              | `font-alliance text-lg font-normal leading-none`                           | `flex gap-1.5` so badges can sit inline                            |\n| Card description        | `text-sm text-accent`                                                     |                                                                    |\n| Body                    | `text-sm`                                                                 | Default for table cells, form values, dialog content               |\n| Label / meta            | `text-xs text-accent`                                                     | Field labels, table column captions, metadata                      |\n| Field helper / feedback | `text-2xs leading-snug`                                                   | Defaults via `FieldDescription` and `FieldFeedback`                |\n| Badge                   | `text-xs` (auto, via `Badge`)                                             | Never override                                                     |\n| Button                  | `text-sm` (md/sm/lg), `text-xs` (xs)                                      | Auto via `Button` sizing                                           |\n\nOn brand-forward surfaces, a short phrase may use the `text-highlight`\nutility to create the solid project-yellow text block. Keep the phrase short,\nuse it as typographic emphasis rather than status or scope, and do not apply it\ninside routine product UI.\n\nSentence case for descriptions, helper text, and empty states. Title Case for\npage titles, card titles, dialog titles, sheet titles, button labels, badge\nlabels, and dropdown menu items. See §8 for voice rules on copy itself.\n\n## 4. Component Stylings\n\nPrefer v3 components from [`frontend/src/components/v3/`](frontend/src/components/v3)\nfor new UI. The v2 library is legacy; use it for new work only when v3 does not\ncover the required behavior. Existing v2 consumers marked blocked in the\n[lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) need\nconsumer-specific parity checks before replacement, even on mixed pages.\n\nFor exact tokens, classes, and supported variants, read component source and\nthe story when present; stories may not enumerate every implemented variant.\n\n### Reading the stories\n\nWhere present, stories may use these conventions:\n\n- **`Variant: X`** stories — one per prop-driven variant (e.g. `Variant: Outline`).\n- **`Example: X`** stories — composition recipes (e.g. `Example: With Header`,\n  `Example: Inside Card / Sheet / Dialog`).\n- Story descriptions or component-level metadata may explain when to use a composition.\n\nUse the closest example as a starting point, not a mandatory composition.\nSome stories use other names or omit descriptions; check component props,\nexports, and representative consumers when guidance is absent.\n\nRun Storybook with `cd frontend && npm run storybook` (port 6006) to preview.\n\n### Component inventory\n\nUse these tables to find the component for a given intent. For props,\nvariants, sizes, and class lists, open the source and any available story.\nThe [generic](frontend/src/components/v3/generic/index.ts) and\n[platform](frontend/src/components/v3/platform/index.ts) barrels also expose\nspecialized primitives not listed here (for example `CodeBlock`, `CopyButton`,\n`RadioGroup`, `TagsInput`, `FileDropzone`, `DurationInput`, `ColorPicker`).\n\n#### Actions\n\n| Component                                                                       | Reach for this when…                                                                                                                                                                                                                          |\n| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [`Button`](frontend/src/components/v3/generic/Button/Button.tsx)                | A text-bearing button — primary or secondary action.                                                                                                                                                                                          |\n| [`ButtonBadge`](frontend/src/components/v3/generic/Button/ButtonBadge.tsx)      | A short decorative flag attached to a Button corner—such as \"Last Used\", \"New\", or \"Recommended\". It inherits Badge typography, sizing, radius, border, and casing; only its corner positioning and solidified semantic tint are specialized. |\n| [`IconButton`](frontend/src/components/v3/generic/IconButton/IconButton.tsx)    | A square icon-only button — toolbars, row actions, compact triggers. Always `aria-label`.                                                                                                                                                     |\n| [`ButtonGroup`](frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx) | Visually join related controls — toolbars, segmented controls, split buttons, key-value chips.                                                                                                                                                |\n| [`Dropdown`](frontend/src/components/v3/generic/Dropdown/Dropdown.tsx)          | An action menu — overflow `⋯`, split-button alternates, contextual lists.                                                                                                                                                                     |\n\n#### Forms\n\n| Component                                                                                                                                 | Reach for this when…                                                                                        |\n| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| [`Field`](frontend/src/components/v3/generic/Field/Field.tsx)                                                                             | Compose labeled or validated form controls with the slots they need. `FieldContent`, description, and error are optional; a standalone filter/search control need not be a form field. |\n| [`Label`](frontend/src/components/v3/generic/Label/Label.tsx)                                                                             | Standalone form label outside a `Field`.                                                                    |\n| [`Input`](frontend/src/components/v3/generic/Input/Input.tsx) / [`TextArea`](frontend/src/components/v3/generic/TextArea/TextArea.tsx)    | Single-line / multi-line text entry.                                                                        |\n| [`InputGroup`](frontend/src/components/v3/generic/InputGroup/InputGroup.tsx)                                                              | Input with left/right addons — search bars, prefixed values.                                                |\n| [`Select`](frontend/src/components/v3/generic/Select/Select.tsx)                                                                          | Non-searchable single-select with a short, known option list.                                               |\n| [`Combobox`](frontend/src/components/v3/generic/Combobox/Combobox.tsx)                                                                    | Searchable single- or multi-select, including grouped/rich rows and inline or dialog creation when its contract fits. |\n| [`ReactSelect`](frontend/src/components/v3/generic/ReactSelect/index.ts)                                                                  | Deprecated compatibility for consumers needing react-select-specific overrides; see the lifecycle ledger. |\n| [`Toggle`](frontend/src/components/v3/generic/Toggle/Toggle.tsx) / [`Checkbox`](frontend/src/components/v3/generic/Checkbox/Checkbox.tsx) | Boolean toggle / multi-select boolean.                                                                      |\n| [`Calendar`](frontend/src/components/v3/generic/Calendar/Calendar.tsx)                                                                    | Date / multi-date / range picker primitive.                                                                 |\n| [`DateRangeFilter`](frontend/src/components/v3/platform/DateRangeFilter/DateRangeFilter.tsx)                                               | Platform date-range filter with presets — for filter bars.                                                  |\n| [`SecretInput`](frontend/src/components/v3/platform/SecretInput/SecretInput.tsx)                                                           | Platform secret-value editor with mask toggle and `${var}` highlighting.                                    |\n| [`PasswordGenerator`](frontend/src/components/v3/platform/PasswordGenerator/PasswordGenerator.tsx)                                         | Platform password generator against project secret-validation rules.                                        |\n| [`EmailInput`](frontend/src/components/v3/platform/EmailInput/EmailInput.tsx)                                                              | Platform chip field for one or more free-text email addresses. Validate it with `emailListSchema` from `@app/helpers/email`; check single addresses or picker-created invitees with `isValidEmail` rather than a local regex or `z.string().email()`. |\n\n#### Containers & overlays\n\n| Component                                                                       | Reach for this when…                                                                                                         |\n| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| [`Card`](frontend/src/components/v3/generic/Card/Card.tsx)                      | Default section container — tables, filters, forms, empty states all live in a Card.                                         |\n| [`Sheet`](frontend/src/components/v3/generic/Sheet/Sheet.tsx)                   | Panel for larger forms or workspaces; right is the usual side. Choose `size=\"form\"`, `\"wide\"`, or `\"workspace\"` by content, using the [size examples](frontend/src/components/v3/generic/Sheet/Sheet.stories.tsx); other sides serve specific flows. |\n| [`Dialog`](frontend/src/components/v3/generic/Dialog/Dialog.tsx)                | Centered modal — **use for small create/edit forms** (1–2 fields, single confirmation prompt) and short interactive prompts. |\n| [`AlertDialog`](frontend/src/components/v3/generic/AlertDialog/AlertDialog.tsx) | Confirm an action (destructive included). Replaces `confirm()`.                                                              |\n| [`Popover`](frontend/src/components/v3/generic/Popover/Popover.tsx)             | Anchored floating panel — filters, pickers, contextual UI.                                                                   |\n| [`Tooltip`](frontend/src/components/v3/generic/Tooltip/Tooltip.tsx)             | Small floating annotation on hover/focus.                                                                                    |\n| [`Accordion`](frontend/src/components/v3/generic/Accordion/Accordion.tsx)       | Collapsible sections.                                                                                                        |\n\n#### Data display\n\n| Component                                                                    | Reach for this when…                                                                                                  |\n| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| [`Table`](frontend/src/components/v3/generic/Table/Table.tsx)                | Read-mostly list of records with sortable columns. Pair with `Empty` + `Pagination`.                                  |\n| [`DataGrid`](frontend/src/components/v3/generic/DataGrid/data-grid.tsx)      | Editable spreadsheet-style grid — copy/paste, multi-cell selection, keyboard nav. Use only when `Table` isn't enough. |\n| [`Pagination`](frontend/src/components/v3/generic/Pagination/Pagination.tsx) | Page controls under a Table or list.                                                                                  |\n| [`Item`](frontend/src/components/v3/generic/Item/Item.tsx)                   | Vertically-stacked list rows with shared spacing — when a `Table` is too heavy.                                       |\n| [`Detail`](frontend/src/components/v3/generic/Detail/Detail.tsx)             | Read-only label/value pairs in a detail view.                                                                         |\n| [`Badge`](frontend/src/components/v3/generic/Badge/Badge.tsx)                | Small label or chip — status, scope tag, key/value pair.                                                              |\n| [`SelectedActionBar`](frontend/src/components/v3/generic/SelectedActionBar/SelectedActionBar.tsx) | Floating batch actions for a selected set of records. |\n\n#### Navigation & search\n\n| Component                                                                    | Reach for this when…                              |\n| ---------------------------------------------------------------------------- | ------------------------------------------------- |\n| [`Sidebar`](frontend/src/components/v3/generic/Sidebar/Sidebar.tsx)          | Scope-aware product navigation panel.             |\n| [`Breadcrumb`](frontend/src/components/v3/generic/Breadcrumb/Breadcrumb.tsx) | Hierarchical location trail at the top of a page. |\n| [`Command`](frontend/src/components/v3/generic/Command/Command.tsx)          | Search-driven command palette / typeahead list.   |\n| [`Tabs`](frontend/src/components/v3/generic/Tabs/Tabs.tsx)                   | Switch between related views in the same context. |\n| [`Stepper`](frontend/src/components/v3/generic/Stepper/Stepper.tsx)          | Show progress through a staged workflow.          |\n\n#### Feedback & loading\n\n| Component                                                                     | Reach for this when…                                             |\n| ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |\n| [`Alert`](frontend/src/components/v3/generic/Alert/Alert.tsx)                 | Inline message banner inside a page or Card.                     |\n| [`Toast`](frontend/src/components/v3/generic/Toast/Toast.tsx)                 | Transient post-action feedback. Replaces `alert()`.              |\n| [`Empty`](frontend/src/components/v3/generic/Empty/Empty.tsx)                 | Zero-state placeholder — pair with Table, list, or empty filter. |\n| [`Skeleton`](frontend/src/components/v3/generic/Skeleton/Skeleton.tsx)        | Shimmer placeholder while data is loading.                       |\n| [`Spinner`](frontend/src/components/v3/generic/Spinner/Spinner.tsx)           | Project-accented spinner by default; choose `xs`/`sm` for compact waits. |\n| [`Loader`](frontend/src/components/v3/generic/Loader/Loader.tsx)              | Branded loading animation — pending controls and page waits.     |\n| [`PageLoader`](frontend/src/components/v3/platform/PageLoader/PageLoader.tsx) | Centered Lottie spinner for full-page loading.                   |\n\n**Reduced motion.** When the operating system requests reduced motion, `Loader`\nand `PageLoader` hold the animation on its first frame instead of looping. The\nmark stays fully visible either way, because these Lotties animate only a\ntrim-path offset over an always-drawn mark. `role=\"status\"` is present in both\nstates, so the wait is still announced.\n\n#### Atoms & domain\n\n| Component                                                                                                         | Reach for this when…                                                                  |\n| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| [`Separator`](frontend/src/components/v3/generic/Separator/Separator.tsx)                                         | Horizontal/vertical divider.                                                          |\n| [`AccessRestrictedNotice`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedNotice.tsx) | Inline restriction for a section. |\n| [`AccessRestrictedDialog`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedDialog.tsx) | Page/tab access gate rendered in document flow, despite its name. |\n| [`ScopeIcons`](frontend/src/components/v3/platform/ScopeIcons.tsx)                                                | `OrgIcon` / `SubOrgIcon` / `ProjectIcon` / `InstanceIcon` — use when intent is scope. |\n| [`SecretManagerResources`](frontend/src/components/v3/platform/SecretManagerResources/SecretManagerResources.ts) | Canonical Secret Manager resource catalog (icon, color classes, name, slug, permission subject). |\n| [`ProjectPermissionSubjects`](frontend/src/components/v3/platform/ProjectPermissionSubjects/ProjectPermissionSubjects.ts) | Icon and tile color for every project policy subject (`ProjectPermissionSub`). |\n| [`PageHeader`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx)                                     | Canonical full-width page heading with scope semantics, description, and page actions. |\n| [`DocumentationLinkBadge`](frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.tsx) | Inline \"Documentation\" link badge in `CardTitle`.                                      |\n\n**Icons** — [`lucide-react`](https://lucide.dev). Sizing is bound by the\nhost component; don't override unless necessary.\n\n## 5. Layout Principles\n\n- **Page container** — `max-w-8xl` (88rem) centered, `bg-page`.\n- **Page header** — v3 `PageHeader` with scope/product icon + underlined `h1` + description. Import it from `@app/components/v3` and set `scope` to `org`, `namespace`, `instance`, or the applicable `ProjectType`. See [`PageHeader.tsx`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx).\n- **Header boundary** — `PageHeader` owns the normal 40px bottom margin (`mb-10`). Do not repeat that margin at ordinary callers or add a parent row gap between the header and body. Keep independent card/section spacing in the body layout. Use `className` for genuine compact/responsive cases, such as `mb-6 md:mb-10` for Overview; see the header's spacing stories.\n- **Section** — one `Card` per logical section. Title + optional `DocumentationLinkBadge` in `CardHeader`; primary action in `CardAction` (top-right).\n- **Tables inside Cards** — filters and search sit in the `CardHeader` above the table; pagination sits in the `CardFooter` or bottom of `CardContent`. **Empty state** — when the table has no rows (and isn't loading), hide the `Table` entirely and render `Empty` in its place; never leave a column header floating above a blank body. Add `className=\"border\"` to `Empty` whenever it's nested in a `Card`, `Sheet`, or `Dialog` so the dashed frame is visible against the parent surface (the component ships dashed-but-borderless on purpose for page-level use).\n- **Forms inside Sheets/Dialog** — create / edit flows open in a Sheet or Dialog, never inline, never as a full-page route. **Pick by form size:** small forms (1–2 fields, e.g. \"Add domain\", \"Rename\") go in a centered `Dialog`; large or multi-step forms (multiple fields, scrollable detail, file uploads, wizard steps) go in a right-side `Sheet`. When in doubt, default to Dialog — Sheet is for cases where Dialog feels cramped.\n- **Authentication form stacks** — use `CardContent className=\"flex flex-col gap-4\"` for the major vertical rhythm between fields, validation groups, and primary actions. Use a nested `flex flex-col gap-2` only for compact sets of equivalent actions such as social or SSO provider buttons. Use `FieldSeparator` between alternate authentication paths. Do not recreate sibling spacing with `mt-*`, `mb-*`, or `py-*`; keep a wrapper only when it owns animation, responsive visibility, conditional grouping, or tightly coupled control/error content.\n- **Spacing rhythm** — `gap-1.5` (intra-element), `gap-2 / gap-3` (adjacent elements), `p-4 / p-5` (section padding). Card = `p-5 gap-5`; Sheet header/footer = `p-4`.\n- **Sheet and Dialog footers** — actions sit right-aligned with the primary action last: `SheetFooter className=\"justify-end border-t\"` (`DialogFooter` is right-aligned by default), then Cancel (`ghost`), then the primary button. Back navigation in a multi-step flow sits on the left with `mr-auto`, and wizard footers with status text on the left use `justify-between`. A single full-width action needs no alignment class.\n\n## 6. Depth & Elevation\n\nDepth is conveyed primarily by layered surface tones and borders. The shared\nv3 `Card` includes `shadow-xs` by default; stronger shadows are reserved for\nelements that float (Popover, DropdownMenu, Sheet, SelectedActionBar).\n\n| Layer           | Surface                                  | Border                              |\n| --------------- | ---------------------------------------- | ----------------------------------- |\n| Page            | `bg-background`                          | —                                   |\n| Card            | `bg-card`                                | `border-border`                     |\n| Popover / Dropdown main menu | `bg-popover`               | `border-border` + `shadow-md`       |\n| Dropdown submenu | `bg-popover`                           | `border-border` + `shadow-lg`       |\n| Sheet           | `bg-popover`                             | `border-border` + `shadow-lg`       |\n| Floating bar    | `bg-popover`                             | `border-border` + `shadow-floating` |\n| Row hover       | `bg-container-hover`                     | —                                   |\n| Focus           | —                                        | Component-owned focus-visible treatment (Button/IconButton use `ring-2`; default Input uses `ring-[3px]`) |\n| Disabled        | `opacity-50 / 75`, `pointer-events-none` | —                                   |\n\nDo not add a shadow to a Card beyond its shared `shadow-xs` default, or to a\nTable row, standalone Badge, or `ButtonBadge`. An overlapping `ButtonBadge`\nuses its solidified semantic tint—not custom content styling or elevation—to\nremain legible across the control edge.\n\n## 7. Do's and Don'ts\n\n- **DO** choose Badge and Button variants by **intent** (danger / success /\n  warning / info / neutral), not by color preference.\n- **DO** use scope colors (`org`, `sub-org`, `project`, `admin`) to reinforce\n  hierarchy, and product colors (`pam`, `av`) for their own product surfaces.\n- **DO** mask secret values by default. Reveal must be an explicit user\n  action and should be logged.\n- **DO** put large create / edit forms in a right-side Sheet; smaller forms can be in Dialogs.\n- **DO** pair destructive confirmations with the resource name and the\n  consequence (see §9).\n- **DO** cite tokens (`bg-card`) over hex (`#xxxxxx`) in new code.\n- **DON'T** replace existing blocked v2 consumers based on matching component names alone; check the lifecycle ledger and interaction parity first.\n- **DON'T** add ad hoc box-shadows as a depth cue — borders and surface tones do\n  that work. The shared `Card` already has `shadow-xs`; floating elements\n  (Popover, DropdownMenu, Sheet, SelectedActionBar) include stronger shadows.\n- **DON'T** invent new colors. If it isn't in `index.css` `@theme`, it\n  doesn't belong.\n- **DON'T** use `project` yellow, `org` blue, or `sub-org` green as generic\n  accents. They are scope signals; repurposing them creates false hierarchy.\n  The documented brand-forward `text-highlight` treatment and deliberately\n  branded `ButtonBadge` flags are narrow exceptions for authentication and\n  onboarding surfaces.\n- **DON'T** mix font families arbitrarily. Use IBM Plex Sans for UI and body\n  text, Alliance Regular for headings, and JetBrains Mono (`font-mono`) for\n  monospaced content.\n- **DON'T** animate for decoration. Motion should clarify state change only.\n\n## 8. Voice & Content Tone\n\nCopy should read as if written by an engineer for another engineer: direct,\ntechnical, specific. The domain is serious — secrets, access, compliance —\nand the voice reflects that.\n\n### Stance\n\n- Direct. Active voice. Lead with the subject: \"Delete this role\" — not\n  \"This role will be deleted\".\n- Specific. Name the resource, the action, the consequence. Avoid vague\n  verbs (\"handle\", \"manage\") when a precise verb exists (`rotate`, `revoke`,\n  `import`).\n- Calm. No exclamation marks. No second-person cheer (\"Awesome!\",\n  \"You're all set!\"). No emoji.\n- Honest. Never claim speed, power, or ease in UI copy (\"seamless\",\n  \"powerful\", \"blazing-fast\"). Those belong on the marketing site, not here.\n\n### Shapes\n\n- **Labels & buttons** — Title Case, imperative: \"Add Secret\", \"Revoke\n  Access\", \"Rotate Key\".\n- **Title Case applies to** — buttons, badges, dropdown menu items, card\n  titles, dialog titles, sheet titles, page titles, tab labels, table column\n  headers. Anything that names a thing or an action is Title Case.\n- **Descriptions & helper text** — sentence case, one short sentence.\n- **Empty states** — state what's missing, then the next action:\n  \"No secrets yet. Add your first secret to get started.\"\n- **Errors** — name the failure and the remedy. Never \"Something went wrong\":\n  \"Could not rotate secret — token lacks `secrets:write` permission.\"\n- **Destructive confirmation** — name the resource and the consequence \"Delete \"API_KEY\" — this cannot be undone.\"\n- **Success toasts** — past tense, specific: \"Secret \"API_KEY\" created\".\n\n### Secrets & sensitive values\n\nNever include a secret's value in any user-visible copy — UI, logs, toasts,\nerrors, audit trails, or analytics. Refer to secrets by key only. Mask\ntokens and keys in screenshots and docs as well.\n\n### Documentation links\n\nUse `DocumentationLinkBadge` (info variant, external-link icon). Label it\n\"Documentation\" — not \"Learn more\", \"Read docs\", \"See more\".\n\n## 9. Agent Prompt Guide\n\nPasteable prompt fragments for AI coding agents producing new UI.\n\n**Before generating UI for any component:**\n\n1. Find the export in the v3 generic or platform barrel and read its source.\n2. If a sibling story exists, consult its examples and use-when guidance.\n3. Pick a supported variant by intent and current scope/product, not color preference.\n\n**Adding a section to an existing page:**\n\n> Wrap the section in a `Card` from `@app/components/v3`. Use `CardHeader`\n> with `CardTitle` + optional `CardDescription` + `CardAction` for the\n> top-right primary button (the variant for the current scope or product,\n> when supported). Put the table or content in `CardContent`.\n\n**A new create/edit form:**\n\n> Pick the container by form size. **Small forms (1–2 fields, e.g. \"Add\n> domain\", \"Rename\"):** centered `Dialog` (`Dialog`, `DialogContent`,\n> `DialogHeader` with `DialogTitle` + `DialogDescription`, `DialogFooter`\n> with the action buttons). **Large or multi-step forms (many fields,\n> scrollable detail, wizards):** right-side `Sheet` (`Sheet`, `SheetContent`,\n> `SheetHeader` with `SheetTitle` + `SheetDescription`, `SheetFooter` with\n> the action buttons). Use `react-hook-form` with a Zod resolver in both\n> cases. Compose labeled/validated inputs in `Field` with `FieldLabel` and\n> only the description, content layout, or feedback slots needed. Primary\n> button variant follows the current scope/product (`project`, `org`,\n> `sub-org`, `pam`, or `av`, when supported); cancel is `ghost`.\n\n**A status indicator:**\n\n> Use `Badge` from `@app/components/v3`. Pick the variant by intent:\n> `danger` for errors or expired access, `warning` for stale or\n> attention-warranting, `success` for healthy / completed, `info` for\n> informational, `neutral` for disabled / empty, `project` / `org` /\n> `sub-org` for scope references. Include a matching Lucide icon as the\n> first child.\n\n**A destructive confirmation:**\n\n> Use `AlertDialog`. Title: \"Delete `<resource-name>`\". Description: one\n> sentence naming the consequence, ending with \"This cannot be undone.\"\n> Confirm button is variant `danger`. Cancel button is variant `outline`.\n\n**Unsaved changes:**\n\n> Use `DiscardChangesAlertDialog` with `useDiscardChangesGuard`. Title:\n> \"Discard Changes?\". Description names what will be lost. Confirm is\n> Discard (`danger`); cancel is Keep Editing. Overlay editors intercept\n> close with `requestDiscard`. Do not use `window.confirm`.\n\n**A documentation link in a section:**\n\n> Use `DocumentationLinkBadge` from `@app/components/v3/platform`. Place it\n> in the `CardTitle` next to the section name.\n\n**Refer to:**\n\n- [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx) — canonical semantic reference for variant choice.\n- [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) — full-page composition reference (v3 PageHeader, Card-with-table, Create Secret Sheet, filters, DropdownMenu + ButtonGroup).\n- [`AccessControlPage`](frontend/src/pages/project/AccessControlPage) — full-page reference (permission-gated actions, `DocumentationLinkBadge`, role badges with `ClockAlertIcon` for expired access).\n- §8 above for any user-visible copy.\n\n## Appendix: Iteration Guide\n\n1. **Run Storybook** — `cd frontend && npm run storybook` (port 6006). Open\n   Badge, Button, Card, Table, Sheet first.\n2. **Read the two reference pages** — `OverviewPage` and `AccessControlPage`\n   render the full v3 vocabulary in production.\n3. **Tokens live in `index.css`** — `@theme` blocks and theme overrides. Never\n   introduce a hex that is not here.\n4. **Adding a variant** — extend the `cva()` block in the component and add\n   a story when appropriate. Match the existing variant family rather than\n   imposing a tint recipe on neutral, outline, or product variants.\n5. **Prefer v3 for new code** — use v2 only where needed for behavior not covered\n   by v3. Existing blocked v2 consumers migrate after lifecycle parity review.\n6. **Before merging** — `make reviewable-ui` (lint + type-check).\n7. **When in doubt** — mirror `OverviewPage`.\n","isInternal":false,"tokens":7709,"sizeBytes":39180},{"name":"SKILL.md","path":".agents/skills/docs-style/SKILL.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/.agents/skills/docs-style/SKILL.md","title":"docs-style","category":"anthropic-skill","format":"markdown","content":"---\nname: docs-style\ndescription: Write, edit, and review Infisical documentation to the house style guide. Use whenever creating or changing any file under docs/ (.mdx pages, snippets, docs.json navigation), when asked to document a feature, write or update a guide, or review a docs diff or pull request. Covers running the Vale linter and the sentence-level review that Vale can't do.\n---\n\n# Infisical docs style\n\n`docs/STYLE_GUIDE.md` holds every rule for Infisical documentation. This skill doesn't repeat\nthe rules. This skill describes how to apply them.\n\n**Before you write or review anything, read all of `docs/STYLE_GUIDE.md` with the Read tool.**\nDon't work from a summary, a diff, or your memory of the guide. Each rule in the guide comes\nwith an \"Instead of\" and \"Write\" example, and the examples show what the rule means better\nthan any summary can.\n\n## Templates\n\nTo check if the page you're documenting has a template to follow, start from the matching file in\n[templates/](templates/README.md). The README says which template fits and how the snippets\nshared between integration pages work.\n\n## Before you write\n\nFind the facts yourself, in the code and the running product, before you draft anything. Don't\nask a question that the code can answer. Ask only about what the code can't tell you, such as\nwhy a feature exists or who it's for.\n\nCheck every fact against the product, not against other docs pages, because other docs pages\nare often out of date:\n\n- **UI steps:** Take button, tab, and field names and the order of screens from the running\n  product, or from the frontend source if you can't reach the running product\n- **Behavior claims:** Read the code path before you describe what the product does; if\n  another docs page seems to contradict the code, find out why before you edit either page\n\n## Reviewing\n\nReview your own drafts the same way you review anyone else's. Work in the following order:\ncut first, then check the structure, then check the sentences, then review the edited page\nagain. Rewriting a sentence is wasted work if the sentence shouldn't be on the page, or if it's\nin the wrong section.\n\n**1. Run the linter.** Run `make lint-docs-branch` from the repository root. Every rule except\n`Infisical.EmDashes` reports at error level, so any other finding fails the command, including\n`Infisical.UIActions` and `Infisical.Contractions`, the two rules most likely to report problems.\n`Infisical.EmDashes` reports at warning level and doesn't change the exit code, so also read the\nwarnings the command prints.\n\nVale doesn't check text indented four or more spaces inside a Mintlify component, and about\nhalf of the text in this repository is indented that way. If Vale reports no problems on a\npage with nested components, the nested text still needs the checks in steps 2, 4, and 5.\n\n**2. Cut what the reader won't act on.** Read the page one paragraph at a time. For each fact,\nask what the reader would do differently after reading it, as \"Leave out what the reader won't\nact on\" in section 2 of the guide describes. If the answer is nothing, the fact is a finding,\neven if it's true and you checked it in the code.\n\nNone of the other steps catches this. A sentence that shouldn't be on the page can still pass\nevery rule in section 5, and if you restructure it instead of cutting it, the page gets more\nheadings and lists without getting any shorter.\n\nApply this step to everything on the page: content that was already there, content someone else\nwrote, and your own draft. Treat each cut like any other finding.\n\n**3. Check the structure against sections 1, 4, 6, 7, 8, and 10 of the guide.** Check every\nsection of the page against each of those sections of the guide, including the page sections\nthat look fine at first glance.\n\n**4. Check every sentence against sections 3 and 5 of the guide.** Go through the sections one\n`###` heading at a time. Each heading names one rule, so for each heading, read every sentence\non the page and check whether the sentence breaks that rule. Then read every sentence out loud,\nas section 5 describes.\n\n**5. Check the formatting against section 11 of the guide.** No automated check covers any\nrule in section 11, so check each rule by reading the page.\n\nBefore you hand a draft back, check the draft for every mistake you've already been corrected\non, including corrections earlier in the same conversation.\n\n**6. Apply the findings.** Edit the page to fix every finding. Don't commit anything.\n\nIf the person you're working with wrote or edited the prose themselves in this conversation,\ndon't change their sentences yet. Report those findings in the format in step 8, and ask once\nwhether to apply them.\n\n**7. Review the edited page.** Run steps 1 to 5 again on the whole edited page, from the top.\nYour own edits create new problems. For example, turning part of a paragraph into a list can\nleave several short paragraphs in a row, and moving content can put a section out of order.\nFix what you find before you hand the page back.\n\n**8. Report what you changed.** Write one change per line: the location as\n`path/to/file.mdx:12`, the rule the change fixes, the text before the change, and the text\nafter it, or \"cut\" for a finding from step 2. Group the changes by file, and include the fixes\nfrom step 7.\n\n## When Vale is wrong\n\n`docs/CONTRIBUTING.MD` describes how to add a word to the vocabulary, enforce a spelling, and\nturn off a rule for one line with `{/* vale Infisical.RuleName = NO */}`. Turn off a rule only\non a specific line where Vale is actually wrong, never to hide problems across a whole page.\n","frontmatter":{"name":"docs-style","description":"Write, edit, and review Infisical documentation to the house style guide. Use whenever creating or changing any file under docs/ (.mdx pages, snippets, docs.json navigation), when asked to document a feature, write or update a guide, or review a docs diff or pull request. Covers running the Vale linter and the sentence-level review that Vale can't do."},"isInternal":false,"tokens":1319,"sizeBytes":5606},{"name":"README.md","path":".agents/skills/docs-style/templates/README.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/.agents/skills/docs-style/templates/README.md","title":"Templates Skill","category":"anthropic-skill","format":"markdown","content":"# Docs page templates\n\nEach template gives the section order, components, and fixed wording for one type of page.\n\n| Template | Use for | Lives at |\n| --- | --- | --- |\n| [app-connection.mdx](app-connection.mdx) | An app connection guide | `docs/integrations/app-connections/{product}.mdx` |\n| [secret-sync.mdx](secret-sync.mdx) | A secret sync guide | `docs/integrations/secret-syncs/{product}.mdx` |\n| [secret-rotation.mdx](secret-rotation.mdx) | A secret rotation guide | `docs/documentation/platform/secret-rotation/{rotation}.mdx` |\n| [quickstart.mdx](quickstart.mdx) | A quickstart or task guide that ends in a working result | Varies |\n| [landing-page.mdx](landing-page.mdx) | The overview page at the top of a docs section | `{section}/overview.mdx` |\n| [concept-page.mdx](concept-page.mdx) | A page about one concept and the tasks that involve it | Varies |\n\n## How to read a template\n\nEvery template uses the same four kinds of markup:\n\n| Markup | Meaning | What to do |\n| --- | --- | --- |\n| Plain text | Fixed wording | Keep the wording if the wording is true for this product. Check every claim in it against the product first |\n| `{Description}` | A placeholder | Replace the whole placeholder with your own text. The placeholder describes what goes there; it never contains text to copy |\n| `{/* IF: condition */}` ... `{/* END IF */}` | A conditional block | Keep the block, without the two markers, only if the condition is true. Otherwise delete the block |\n| `{/* NOTE: ... */}` | Guidance for the writer | Follow the guidance, then delete the comment |\n\nBraces inside a code span, such as `` `{{secretKey}}` ``, are literal text the product uses, not\nplaceholders.\n\nBefore you publish, delete every `{/* ... */}` comment and search the page for `{` to find any\nplaceholder you missed.\n\n## How closely to follow a template\n\nA template is the default structure for a page type, not a form to fill in:\n\n- **Leave out what the product doesn't have.** If a sync has no destination fields, or a\n  quickstart has no second sub-task, delete that part rather than writing filler for it\n- **Add what the product needs.** If the product needs a step or a section the template doesn't\n  have, add it where a reader would look for it (STYLE_GUIDE.md section 7)\n- **Check the fixed wording.** Fixed wording is true for most products of the type, not all of\n  them. Button labels, field names, and behavior claims such as \"The name must be\n  slug-friendly\" come from one version of the product, so check each one against the frontend\n  or backend before keeping it\n- **Write your own sentences.** The templates contain no example sentences on purpose. For what\n  a finished page looks like, read the example pages named at the top of each template, but\n  don't copy their sentences, which are about a different product\n\n## Shared snippets for an integration\n\nAn app connection page, a sync page, and a rotation page for the same product render the same\nsetup steps and form fields, so those live in snippets under\n`docs/snippets/app-connections/{product}/`:\n\n- `form-fields.mdx`: a bullet list of the connection form's fields, starting with **Name** and\n  **Description** (optional)\n- `{setup}.mdx`, named after the setup task in the third-party product: a `<Steps>` block for\n  that setup, with screenshots\n- `{setup}-for-rotation.mdx`: only when a rotation needs more setup than the connection does;\n  import the base snippet and add the extra steps rather than copying the base steps\n\nA link in `form-fields.mdx` to a step in the setup snippet resolves on whichever page renders\nboth snippets. Link to the `## Step 1:` heading that every page puts the setup snippet under,\nand give that heading the same text on the connection, sync, and rotation pages. Linking to a\nstep title doesn't work, because steps don't have a `title` prop.\n\nLinks from a sync or rotation page to the organization-level connection steps use\n`/integrations/app-connections/{product}#step-2-create-the-app-connection`.\n\n## Steps that depend on the integration\n\nWhen a connection needs different setup for each integration that uses it, such as a separate\npermission policy per integration, put a use case picker at the top of the page instead of a tab\nper integration. `snippets/UseCasePicker.jsx` exports two components:\n\n- `<UseCasePicker prefix=\"I want my {Product} connection to work for\" groups={...} />`: a\n  sentence ending in a dropdown where the reader picks one or more integrations\n- `<UseCase use=\"{id}\">`: shows its children only when the reader picked that id, or when they\n  picked nothing, so the page reads as the complete guide by default. `use` takes a\n  comma-separated list for content that several integrations share. Add `hideWhenEmpty` for\n  content that only makes sense next to a filtered result, such as setup notes that follow a\n  combined policy, so it stays hidden until the reader picks something\n\nA `<UseCase>` can hold its own `###` heading. Mintlify builds the \"On this page\" list at build\ntime, so it would still list hidden headings; the picker hides those entries at runtime, so the\nlist matches what's on the page\n\nPut the options in `snippets/app-connections/{product}/use-cases.jsx` as an exported array of\n`{ label, options: [{ id, label }] }` groups, import it on the page next to the component, and\npass it as `groups`. A snippet can't import another snippet, so the page does both imports. The\npicker writes the selection to the URL (`?use=id-1,id-2`), so a reader can share a link to a\nfiltered page.\n\nWrap each integration's content in its own `<UseCase>`, including its card in the\n\"Configure integrations\" step. `integrations/app-connections/aws.mdx` is the example.\n\n## Where the example pages differ from the templates\n\nThe example pages each template names predate some of the style guide's rules. When you read\nan example page, don't copy these:\n\n- Titled steps (`<Step title=\"...\" titleSize=\"h3\">`); the guide says to leave out `title`\n- Periods at the end of bullets\n- Em dashes, such as \"This guide doesn't configure any behavior—it just authenticates\"\n- Button labels in the 1Password and Databricks sync and rotation pages, which come from older\n  versions of the wizards (**Next** instead of **Continue**, **Disable Secret Deletion**\n  instead of **Prevent secret deletion**)\n","isInternal":false,"tokens":1506,"sizeBytes":6317},{"name":"SKILL.md","path":".agents/skills/testing-secrets-ui/SKILL.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/.agents/skills/testing-secrets-ui/SKILL.md","title":"testing-secrets-ui","category":"anthropic-skill","format":"markdown","content":"---\nname: testing-secrets-ui\ndescription: Test the Infisical secrets management UI end-to-end. Use when verifying secret CRUD operations, URL encoding fixes, or frontend API layer changes.\n---\n\n# Testing Secrets Management UI\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- The repo cloned locally\n\n## Setting Up Local Dev Environment\n\n1. Start the full stack:\n   ```bash\n   make up-dev\n   ```\n   This runs `docker-compose.dev.yml` which starts PostgreSQL, Redis, backend (Fastify), frontend (React/Vite), and Nginx.\n\n2. Wait for all containers to be healthy. The app runs on `http://localhost:8080`.\n\n3. **Common issue: Migration lock**\n   If the backend fails to start with \"Migration table is already locked\", unlock it:\n   ```bash\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_lock SET is_locked = 0;\"\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_startup_lock SET is_locked = 0;\"\n   docker restart infisical-dev-api\n   ```\n   Wait ~15 seconds for migrations to complete.\n\n4. Create a test account at `http://localhost:8080/signup` (local dev has no email verification).\n\n5. Create a test organization and project after signup.\n\n## Testing Secret CRUD Operations\n\n### Create Secret\n- Navigate to a project's Secret Dashboard (Project Overview page)\n- Click \"+ Add Secret\" to open the Create Secret modal\n- Fill in Environment (defaults to Development), Key, and Value\n- Click \"Create Secret\"\n- The modal uses the `createSecret` function from `frontend/src/hooks/api/secrets/mutations.tsx`\n\n### Update Secret (Inline Edit)\n- On the dashboard, click the secret's key field to edit it inline\n- Change the key or value\n- Click the save icon (floppy disk) that appears\n- This uses the `useUpdateSecretV3` mutation from `mutations.tsx`\n\n### Testing URL Encoding\nWhen testing that special characters in secret keys are handled correctly:\n- Try creating/updating secrets with `/`, `%`, `#`, `?`, `&` in the key name\n- Verify the browser network tab shows the key is URL-encoded (e.g., `/` becomes `%2F`)\n- Check backend logs: `docker logs infisical-dev-api 2>&1 | grep \"secret\"` to confirm the encoded URL\n- The error should be a \"Validation Error\" (Fastify param validation), NOT \"Route not found\"\n\n## Key Files\n\n- `frontend/src/hooks/api/secrets/mutations.tsx` — Secret create/update/delete mutations\n- `frontend/src/hooks/api/secrets/queries.tsx` — Secret query hooks\n- Backend logs: `docker logs infisical-dev-api`\n- Database: `docker exec -it infisical-dev-db psql -U infisical -d infisical`\n\n## Devin Secrets Needed\n\nNo external secrets are required for local dev testing. The local environment uses:\n- Database: PostgreSQL with user `infisical` (configured in docker-compose.dev.yml)\n- All services run locally via Docker\n","frontmatter":{"name":"testing-secrets-ui","description":"Test the Infisical secrets management UI end-to-end. Use when verifying secret CRUD operations, URL encoding fixes, or frontend API layer changes."},"isInternal":false,"tokens":691,"sizeBytes":2844},{"name":"CLAUDE.md","path":"backend-go/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/backend-go/CLAUDE.md","title":"backend-go - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# backend-go\n\nPartial Go rewrite of the Node.js backend using chi + oapi-codegen + raw pgx queries.\n\n## Commands\n\n```\nmake build            # build binary\nmake dev              # hot-reload via docker-compose + air\nmake test             # run all tests (unit + integration)\nmake test-unit        # unit tests only (./internal/...)\nmake test-integration # integration tests only (./tests/..., testcontainers)\nmake test-hsm         # HSM tests (requires Docker, builds test container)\nmake lint             # must pass before submission\nmake lint-fix         # auto-fix linting issues\nmake generate         # run go generate ./... for oapi-codegen\n```\n\n## Code Rules\n\n- **Linting**: `make lint` must pass. No `//nolint` without justification.\n- **Service constructor**: `(ctx context.Context, logger *slog.Logger, deps *Deps)`. Deps struct ends in `Deps`, passed by pointer. Every I/O method takes `ctx` first.\n- **Logger**: pass `*slog.Logger` via constructor — never `slog.Default()`.\n- **Interfaces**: consumer-defined. Accept interfaces, not concrete types.\n- **Visibility**: expose only what's needed.\n- **No DAL layer**: services use `pg.DB` directly with raw pgx.\n- **Error wrapping**: wrap DB errors at service level with context:\n  ```go\n  errutil.DatabaseErr(\"Failed to load\").WithErrf(\"FuncName(arg=%s): %w\", arg, err)\n  errutil.Forbidden(\"Access denied\").WithErrf(\"FuncName: permission check failed\")\n  ```\n- **Lean code**: inline single-use helpers.\n- **Test naming**: `Test<FunctionName>_<Scenario>`.\n\n## Architecture\n\n```\ncmd/infisical/main.go           # Entry point\ninternal/\n├── config/                     # Env config via koanf\n├── database/\n│   ├── pg/                     # DB interface (primary + replicas via pgxpool)\n│   │   ├── qb/                 # Query builders (Where, Insert, Update, Delete)\n│   │   ├── sqln/               # SQL nesting (GroupRows for LEFT JOIN flattening)\n│   │   └── pglock/             # PostgreSQL advisory locks\n│   └── redis/                  # Redis client\n├── libs/                       # crypto, errutil, fn, logutil\n├── server/\n│   ├── api/                    # Endpoint implementations + DI wiring\n│   │   ├── shared/             # Shared types (Error, ValidationError) + ErrorHandler\n│   │   ├── platform/           # Platform handlers (projects, etc.)\n│   │   └── secretmanager/      # Secret manager handlers\n│   └── middlewares/            # HTTP middlewares (RequireAuth, etc.)\n├── services/                   # Shared business logic (auth, permission, kms, ...)\n└── keystore/                   # Redis key-value operations\ntests/                          # Integration tests (external test packages)\n├── infra/                      # Test infrastructure (testcontainers, helpers)\n├── platform/                   # Platform service tests\n│   ├── auth/                   # Authentication handler tests\n│   ├── externalkms/            # External KMS (AWS/GCP) tests\n│   ├── hsm/                    # HSM tests (container-based)\n│   ├── kms/                    # KMS service tests\n│   ├── permission/             # Permission system tests\n│   ├── projects/               # Projects handler tests\n│   └── ratelimit/              # Rate limiting tests\n└── secretmanager/\n    └── secrets/                # Secrets API tests (list, get, permissions)\n```\n\n**Two tiers:**\n- `server/api/` — DI wiring + endpoint implementations. Handlers 1:1 with endpoints.\n- `services/` — Business logic, uses `pg.DB` directly.\n\n### Wiring (Composition Root)\n\nAll service/handler initialization lives in `server/api/`:\n\n```\nserver/api/\n├── api.go                         # Infra, Registry, NewRegistry()\n├── platform_services.go           # newPlatformServices() — kms, auth, permission, etc.\n├── secretmanager_services.go      # newSecretManagerServices() — secret, folder, import\n├── platform_routes.go             # RegisterPlatformRoutes() — chi routing\n├── secretmanager_routes.go        # RegisterSecretManagerRoutes() — chi routing\n├── shared/                        # Shared types + error handler\n├── platform/<handler>/            # Handler implementations\n└── secretmanager/<handler>/       # Handler implementations\n```\n\nInitialize as local variables first, then assign to struct fields.\n\n### Handler Package Structure\n\nEach handler package is generated from an OpenAPI spec using `oapi-codegen`:\n\n```\nserver/api/<product>/<handler>/\n├── openapi.yml    # OpenAPI 3.0 spec (source of truth)\n├── cfg.yaml       # oapi-codegen config\n├── gen.go         # GENERATED types, HTTPAdapter, ServiceInterface\n├── <name>.go      # Handler struct + NewHandler() + //go:generate directive\n└── <endpoint>.go  # One handler method per file (list_secrets.go, get_secret.go, ...)\n```\n\n### Handler Pattern\n\nUses `oapi-codegen` (DoorDash fork) to generate types and HTTP adapters from OpenAPI specs:\n\n**`openapi.yml`** — OpenAPI 3.0 source of truth:\n```yaml\npaths:\n  /api/v4/secrets:\n    get:\n      operationId: ListSecretsV4\n      summary: List secrets\n      parameters:\n        - name: projectId\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: environment\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: secretPath\n          in: query\n          schema:\n            type: string\n      responses:\n        '200':\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ListSecretsV4Response'\n```\n\n**`<handler>.go`** — Handler implements generated `ServiceInterface`:\n```go\n//go:generate go tool oapi-codegen -config cfg.yaml openapi.yml\n\npackage secret\n\nvar _ ServiceInterface = (*Handler)(nil)\n\ntype Handler struct {\n    logger     *slog.Logger\n    permission PermissionService\n    secrets    SecretsService\n}\n\nfunc NewHandler(deps *Deps) *Handler {\n    return &Handler{...}\n}\n\n// ListSecretsV4 implements ServiceInterface\nfunc (h *Handler) ListSecretsV4(ctx context.Context, opts *ListSecretsV4ServiceRequestOptions) (*ListSecretsV4ResponseData, error) {\n    q := opts.Query\n    secretPath := fn.ValueOr(q.SecretPath, \"/\")  // Use helper for optional fields\n    \n    // ... business logic ...\n    \n    return NewListSecretsV4ResponseData(&ListSecretsV4Response{\n        Secrets: secrets,\n    }), nil\n}\n```\n\n**Generated types:**\n- Required fields: plain types (`string`, `int`, etc.)\n- Optional fields: pointers (`*string`, `*int`, etc.)\n- Use `fn.ValueOr(ptr, default)` for safe access with fallback\n- Use `new(value)` to create pointers for optional fields (Go 1.26+)\n\n**Route registration** (`<product>_routes.go`):\n```go\nfunc RegisterSecretManagerRoutes(router chi.Router, logger *slog.Logger, platform *PlatformServices, svc *SecretManagerServices) {\n    secretsHandler := secret.NewHandler(&secret.Deps{...})\n    secretsAdapter := secret.NewHTTPAdapter(secretsHandler, shared.NewErrorHandler(logger))\n\n    router.Group(func(r chi.Router) {\n        r.Use(middlewares.RequireAuth(\n            platform.Authenticator,\n            middlewares.JWTAuth,\n            middlewares.IdentityAccessTokenAuth,\n            middlewares.ServiceTokenAuth,\n        ))\n        r.Get(\"/api/v4/secrets\", secretsAdapter.ListSecretsV4)\n        r.Get(\"/api/v4/secrets/{secretName}\", secretsAdapter.GetSecretByNameV4)\n    })\n}\n```\n\n### Auth Middleware\n\n`RequireAuth` is a fail-closed middleware that validates bearer tokens:\n\n```go\nr.Use(middlewares.RequireAuth(\n    authenticator,\n    middlewares.JWTAuth,              // User session JWT\n    middlewares.IdentityAccessTokenAuth,  // Machine identity token\n    middlewares.ServiceTokenAuth,     // Service token\n))\n```\n\n- No bearer header → 401\n- Bearer present but mode not in allowed list → 401\n- Validator returns error → 401\n- Success: identity stored in context via `auth.WithIdentity()`\n\n### Error Handling\n\n`shared.ErrorHandler` translates errors to HTTP responses:\n- `errutil.Error` → appropriate status + JSON body\n- `runtime.ValidationErrors` → 400 with field errors\n- Unknown errors → 500\n\nAll errors include request ID and are logged appropriately (warn for 4xx, error for 5xx).\n\n### Handler vs Service Responsibilities\n\n**Handlers** (`server/api/`) — thin orchestration:\n1. Extract identity from context\n2. Resolve permissions + IDs (e.g., project ID from slug) via shared services\n3. Call domain service with opts (including `AccessChecker` for permission filtering)\n4. Build API response + audit logs\n\n**Services** (`services/`) — domain logic: data fetching/aggregation, decryption/encryption, business rules (import chaining, secret expansion, personal overrides), and permission filtering via `AccessChecker` in opts.\n\n**AccessChecker pattern**: services accept an optional `AccessChecker` interface in opts. Pass `nil` to skip permission checks (internal/integration use). Permission logic stays in handlers; services enforce it.\n\n### Permission Checker Pattern\n\nPermission checks use `gocasl`. Instead of calling `gocasl.Can()` directly in handlers/services, wrap each domain's checks in a dedicated checker type under `services/permission/<product>/`:\n\n```go\n// services/permission/project/secret_permission.go\ntype SecretAccessChecker struct {\n    ability *gocasl.Ability\n}\n\nfunc NewSecretAccessChecker(ability *gocasl.Ability) *SecretAccessChecker {\n    return &SecretAccessChecker{ability: ability}\n}\n\nfunc (c *SecretAccessChecker) CanReadSecretValue(env, path, key string, tags []string) bool {\n    return gocasl.Can(c.ability, project.SecretActionReadValue, project.SecretSubject{\n        Environment: env, SecretPath: path, SecretName: key, SecretTags: tags,\n    })\n}\n```\n\nUsage:\n\n```go\npermResult, _ := permissionSvc.GetProjectPermission(ctx, args)\nchecker := project.NewSecretAccessChecker(permResult.Permission.Ability)\nif !checker.CanReadSecretValue(env, path, key, tags) {\n    return errutil.Forbidden(\"Cannot read secret\")\n}\n```\n\n**Why**: keeps subject construction and action selection in one place per domain, makes checkers easy to mock in tests, and prevents handler/service code from depending on `gocasl` directly. Existing checkers: `SecretAccessChecker`, `AssumePrivilegeChecker`.\n\nActions and subjects live in `services/permission/project/{actions.go,subjects.go}`:\n- `gocasl.DefineAction[SubjectType](\"verb\")` — typed action constants\n- Subject structs implement `SubjectType() string` and `GetField(string) any` so CASL can match field-conditional rules\n\n**Database access**: services receive `pg.DB` and execute raw pgx queries via helper packages. Use `db.Primary()` for writes, `db.Replica()` for reads.\n\n### Query Helpers\n\n**`qb`** — dynamic SQL builders:\n\n```go\n// WHERE (named args)\nargs := pgx.NamedArgs{\"folderID\": folderID}\nwhere := qb.NewWhere().\n    Add(\"folder_id = @folderID\").\n    AddIf(len(keys) > 0, \"key = ANY(@keys)\")\nargs[\"keys\"] = keys\nrows, _ := db.Replica().Query(ctx, `SELECT * FROM secrets WHERE `+where.String(), args)\n\n// INSERT / UPDATE / DELETE\nsql, args := qb.Insert(\"secrets\", \"id\", \"key\", \"folder_id\").\n    Values(id1, key1, folder1).Values(id2, key2, folder2).Returning(\"*\").Build()\nsql, args := qb.Update(\"secrets\").Set(\"key\", newKey).Where(\"id = @id\", \"id\", id).Returning(\"*\").Build()\nsql, args := qb.Delete(\"secrets\").Where(\"id = @id\", \"id\", id).Build()\n```\n\n**`sqln`** — flatten LEFT JOIN rows into nested structs:\n\n```go\nsecrets := sqln.GroupRows(flatSecrets, sqln.Grouper[Secret, uuid.UUID]{\n    Key:   func(s *Secret) uuid.UUID { return s.ID },\n    Merge: func(existing, row *Secret) {\n        if len(row.Tags) > 0 {\n            existing.Tags = fn.AppendUnique(existing.Tags, row.Tags[0],\n                func(t Tag) uuid.UUID { return t.ID })\n        }\n    },\n})\n```\n\n**`pglock`** — PostgreSQL advisory locks:\n\n```go\ntx, _ := db.Primary().Begin(ctx)\nlock, err := pglock.AcquireBlockingLock(ctx, tx, \"my_lock_id\")\nif err != nil { tx.Rollback(ctx); return err }\ndefer lock.Rollback(ctx)  // safety net\n// ... work ...\nlock.Release(ctx)         // commits\n```\n\n### Writing Raw Queries\n\nUse `pgx.NamedArgs`. Table aliases should be readable abbreviations (`memberships m`, `membership_roles mr`, `additional_privileges ap`) — never single letters.\n\n```go\nrows, _ := db.Replica().Query(ctx, `\n    SELECT id, key, encrypted_value\n    FROM secrets_v2\n    WHERE folder_id = @folderID AND user_id IS NULL\n    ORDER BY key ASC\n`, pgx.NamedArgs{\"folderID\": folderID})\nsecrets, _ := pgx.CollectRows(rows, pgx.RowToStructByName[Secret])\n```\n\n## Wiring a New Feature\n\n1. **Service** in `services/<product>/<name>/` with interface-based deps.\n\n2. **Handler** in `server/api/<product>/<name>/`:\n   - Create `openapi.yml` with endpoints\n   - Create `cfg.yaml` from template (see existing handlers)\n   - Run `go generate ./...` to create `gen.go`\n   - Create `<handler>.go` with `//go:generate` directive, `Handler` struct, `NewHandler()`\n   - Implement `ServiceInterface` methods (one file per endpoint)\n\n3. **Permission checks** (if needed): add action constants in `services/permission/<product>/actions.go`, subject struct in `subjects.go`, and a checker in `<domain>_permission.go`.\n\n4. **Wire routes** in `server/api/<product>_routes.go`:\n   - Create handler and HTTP adapter\n   - Mount routes with appropriate auth middleware\n\n5. **Register** service in `server/api/<product>_services.go`.\n\n6. Run `make test && make lint`.\n","isInternal":false,"tokens":3247,"sizeBytes":13661},{"name":"CLAUDE.md","path":"backend/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/backend/CLAUDE.md","title":"backend - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **backend** package of the Infisical monorepo — a Fastify 4 API server with TypeScript, PostgreSQL via Knex, and BullMQ queues.\n\nThe backend code quality guide is imported below, so it is always in context for work in this package. **Check every change against it, whatever the change is, before calling the task done.** This file covers *where* code goes and *which* pattern to follow; the quality guide covers the minimum any implementation has to handle. It is a deliberately non-exhaustive floor, so the topics it happens to cover are not a filter for whether it applies.\n\n@CODE_QUALITY.md\n\n## Essential Commands\n\nAll commands run from the `backend/` directory:\n\n- `npm run dev` — start dev server with tsx watch + pino-pretty logging (`dev:docker` is the same with the debugger on 9229)\n- `npm run build` — production build via tsdown (`tsdown.config.ts`) with sourcemaps\n- `npm run lint:fix` — ESLint autofix\n- `npm run type:check` — TypeScript check (uses 8GB heap)\n- `make reviewable-api` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n\nThe backend runs on Node 26. TypeScript files run directly through tsx (dev server, scripts, the\n`*-dev` knex commands, and the e2e environment's migration loading); there is no ts-node.\n\n### Build (tsdown)\n\n`tsdown` compiles every module to its own `dist/**/*.mjs` (unbundled ESM) and rewrites `@app/*` to\nrelative paths itself. Invariants that a config change can silently break:\n\n- **The output extension stays `.mjs`.** knex records each migration's file name, extension included, in\n  `infisical_migrations`, so production rows are `*.mjs`. A different extension makes every applied\n  migration look unknown and the boot check refuses to start.\n- **`main` stays the first entry, and entry paths stay absolute.** Rolldown orders each file's imports by\n  execution order, walking entries in order; a glob that sorts another entry ahead of `main` hoists its\n  imports above the telemetry instrumentation. Object entries ignore `root`, so relative paths nest every\n  other module under `dist/src/`.\n- **Rolldown reorders imports, even within one file.** Never rely on import order for a global side effect\n  that another package needs; import it explicitly where it is needed (see `reflect-metadata` in\n  `lib/crypto/pqc/pqc-algorithm.ts`).\n- **`deps.onlyBundle: []` fails the build if a `node_modules` package would be inlined.** That means\n  `src/` imports a package that `package.json` does not declare; declare it instead of loosening the guard.\n\n### Testing\n\nRun both suites from the repo root, which is the same entry point CI uses:\n\n- `make test-api-unit` — unit tests matching `./src/**/*.test.ts`\n- `make test-api-e2e` — e2e tests matching `./e2e-test/**/*.spec.ts`\n- `make test-api-e2e SPEC=<pattern>` — narrow to one spec, e.g. `SPEC=secret-sync`\n- `make up-rotation-databases` — start the databases the secret rotation specs need, required\n  only for a full run (`docker-compose.e2e-dbs.yml`, and the Oracle image is large)\n- `make down-test-suite-containers` — stop everything when finished\n\nThe secret rotation specs reach their databases by compose service name, so\n`docker-compose.e2e-dbs.yml` pins the same compose project as the test stack and shares its\nnetwork. Without `make up-rotation-databases`, those specs fail on connection; the rest pass.\n\nBoth run inside the FIPS image (`Dockerfile.dev.fips`), which carries the native dependencies\nthe suites need (SoftHSM2, the Oracle client, the FIPS OpenSSL build) and pins the Node\nversion, so the host's does not matter. `docker-compose.test.yml` declares the image,\nenvironment, mounts and services; `src`, `e2e-test` and both vitest configs are mounted, so\nediting a test needs no rebuild but changing `package.json` or `tsconfig.json` does.\n\n**`npm run test:e2e` directly is possible but destructive if misconfigured.** The Vitest\nenvironment runs `DROP SCHEMA public CASCADE` on whatever database `.env.test` points at,\nbefore every run. Copy `.env.test.example`, which targets the throwaway stack, and start it\nwith `make up-test-suite-containers`. Never point it at the dev database.\n\nUnit tests go next to source as `*.test.ts` and test pure functions with Vitest globals (`describe`, `test`, `expect`).\n\nE2E tests live in `e2e-test/routes/`. The custom Vitest environment (`e2e-test/vitest-environment-knex.ts`) bootstraps a full server with DB, Redis, and encryption. Tests use injected globals: `testServer` (Fastify instance), `jwtAuthToken` (pre-authenticated JWT). Use `testServer.inject()` for HTTP assertions. Test helpers in `e2e-test/testUtils/` provide CRUD wrappers for secrets, folders, and secret imports. See `e2e-test/routes/v1/org.spec.ts` for a representative e2e test.\n\n#### Writing e2e tests with `testUtils`\n\n**Build test state with the helpers in `e2e-test/testUtils/`, not by hand.** Before writing a\n`testServer.inject` call or a `testDb` insert to set something up, check `testUtils/` for a helper\nthat already does it. If none exists, add one there rather than a local helper in the spec: two\nspecs had each written their own `createTargetUser` before `testUtils/users.ts` existed, and every\ncopy drifts on its own.\n\n- **Isolate each test.** `createIsolatedOrgAndProject` (`fixtures.ts`) gives a test its own org and\n  project, a token scoped to it, and a `cleanup` that deletes the org. Prefer it over the shared\n  `seedData1` org and project, whose state leaks between tests and files.\n- **Don't lean on the seed user as a participant.** When a test needs someone other than the caller\n  (a recipient, a member, a target), create them with `createUser` and `addUserMembership`\n  (`users.ts`). Those write rows directly because signup needs SRP, so the user can be named but\n  cannot log in.\n- **Go through the API for everything else**, with the domain helpers (`secrets.ts`, `folders.ts`,\n  `alerts.ts`, `reminders.ts`, `identities.ts`, ...). Write a helper with `request()` (`request.ts`)\n  so a call is sent when made and a caller can `.expect()` a non-200 or take the `.raw()` response.\n- **Assert on outcomes a user would see**: an API response, an email in `testSmtp`, a message at a\n  fake destination. Read `testDb` only when the API cannot show the thing under test (eg an alert\n  orphaned by a cascade), and say why in a comment.\n- **Wait with `pollUntil`** (`poll.ts`) for anything asynchronous, never a fixed sleep.\n- **Run background jobs on demand** through a pass-through wrapper in `e2e-test/fakes/` (eg\n  `reminder-queue.ts` and `event-outbox-queue.ts`, driven by `runDailyReminders` in `reminders.ts`).\n\n#### Faking a third-party provider\n\n**Never add test-only code to `src/`** — no test-mode enum members, no lookup map entries, no\n`isTestMode` branches. Replace the module instead, from `test.alias` in\n`vitest.e2e.config.mts`, with a double under `e2e-test/fakes/`. Production code stays unaware\na fake exists. `e2e-test/fakes/aws-parameter-store-sync-fns.ts` and its connection counterpart\nare the worked examples, and the pre-existing `./license-fns` alias is the precedent.\n\nFour things decide whether this works:\n\n- **Alias the narrowest specifier.** Entries match the import string, so aliasing one a single\n  file imports (a barrel's `./x-fns` re-export) swaps that seam and leaves the constants,\n  schemas, types, router and lookup maps real.\n- **`test.alias` must stay an array.** Vite's `mergeAlias` concatenates arrays with\n  `test.alias` first, but merges two objects, where the generic `@app` prefix matches before a\n  specific `@app/...` entry and the fake silently stops applying with no error.\n- **Re-export whatever you do not replace.** The alias swaps the whole module, so an export you\n  omit stops existing for every importer of it.\n- **Assert the fake still matches.** Nothing otherwise checks it against the module it\n  replaces. Export an assignment typed as `Pick<typeof RealModule, …>` so a signature change\n  fails type-checking rather than leaving the fake quietly wrong.\n\nA fake must reproduce the contract under test, not merely record its input. The Parameter Store\nfake reimplements the real reconciliation rules (skip empty writes, delete absent keys unless\ndeletion is disabled, respect the key schema), because those rules are the behavior the specs\nexist to pin.\n\n#### FIPS test image and the prebuilt toolchain\n\nCI (`.github/workflows/run-backend-tests.yml`) runs the e2e suite inside a FIPS image built\nfrom `Dockerfile.dev.fips`. The expensive, rarely-changing parts of that image (SoftHSM2,\nOracle Instant Client, the FIPS OpenSSL 3.1.2 build and the PQC OpenSSL 3.5.6 build) live in\n**`Dockerfile.fips-toolchain`**, which is published to\n`ghcr.io/infisical/backend-fips-toolchain` for amd64 and arm64 by\n`.github/workflows/publish-fips-toolchain.yml` on pushes to `main`.\n\n`Dockerfile.dev.fips` consumes it via the `TOOLCHAIN_IMAGE` build arg, so CI and local dev\npull the toolchain instead of spending ~15min compiling it. Consequences worth knowing:\n\n- **Editing `Dockerfile.fips-toolchain` is expensive.** CI pins the image by the content hash\n  of that file, so a PR touching it has no published tag and rebuilds from source (the old,\n  slow path). It publishes once the PR lands on `main`. Keep app-level changes in\n  `Dockerfile.dev.fips`.\n- **Don't add `cache-to: type=gha` back to the image build.** GHA cache is PR-scoped (so it\n  never hits on a PR's first run) and the repo's 10GB cache budget is already full; writing\n  image blobs there evicts the `node_modules` caches of every workflow.\n- **The published image is an internal CI build cache, not a supported artifact.** It is\n  public only so CI and local dev can pull it; it carries no support or compatibility\n  guarantee. It is *not* the FIPS product image; that one is `infisical/infisical-fips`,\n  built from `Dockerfile.fips.standalone-infisical`. OCI labels on the image say the same.\n- **Keep every third-party fetch pinned and checksummed.** SoftHSM2 is pinned to a commit\n  SHA (tags are mutable); both OpenSSL tarballs and the Oracle Instant Client zip are\n  SHA256-verified; the Infisical CLI apt package is version-pinned. Oracle Instant Client\n  is redistributed under the Oracle Free Distribution, Hosting, and Use Terms, which\n  require the license to travel with it, so `/opt/oracle/instantclient_23_26/BASIC_LICENSE`\n  must not be removed.\n\n##### Building the dev stack without GHCR\n\n`docker compose -f docker-compose.dev.yml build backend` pulls the toolchain from GHCR by\ndefault. If GHCR is down, the package is private, you're offline, or you're iterating on the\ntoolchain itself, build it locally once and point the compose build at it:\n\n```bash\n# 1. Build the toolchain from source (~15min, or ~2min if your layer cache is warm)\ndocker build -f backend/Dockerfile.fips-toolchain -t fips-toolchain:local backend\n\n# 2. Prefer .env, so it survives every compose invocation (see the warning below)\necho 'TOOLCHAIN_IMAGE=fips-toolchain:local' >> .env\n\n# ...or pass it per-invocation. Both forms work but are easy to forget:\nTOOLCHAIN_IMAGE=fips-toolchain:local docker compose -f docker-compose.dev.yml build backend\ndocker compose -f docker-compose.dev.yml build --build-arg TOOLCHAIN_IMAGE=fips-toolchain:local backend\n```\n\n**Use `.env` rather than a one-off env var.** The documented start command in\n`docs/contributing/platform/developing.mdx` is `up --build`, which rebuilds the backend and\nre-resolves the `TOOLCHAIN_IMAGE` default, so a per-invocation override silently stops\napplying and you're back to a failing GHCR pull. Compose interpolates the project `.env` for\nevery invocation, so putting it there sticks.\n\nNotes:\n\n- Step 1 is only needed once per change to `Dockerfile.fips-toolchain`. The local tag name is\n  arbitrary; it just has to match what you point `TOOLCHAIN_IMAGE` at.\n- An already-built `backend` image keeps working if GHCR is unreachable, as long as you don't\n  pass `--build`. This only bites on a rebuild.\n- This is not an offline story. The toolchain build itself still fetches Debian packages, both\n  OpenSSL tarballs, SoftHSM sources, and the Oracle client. With no network at all, neither\n  path works and you need a previously built image.\n\n### Database\n\n- `npm run migration:new` — create new migration (interactive prompt)\n- `npm run migration:latest-dev` — run pending migrations (dev, uses `src/db/knexfile.ts`)\n- `npm run migration:rollback-dev` — rollback last migration batch (dev)\n- `npm run generate:schema` — regenerate Zod types from DB into `src/db/schemas/` (always run after migration changes)\n- `npm run seed-dev` — run database seeds\n\n- **Every foreign-key column must be covered by an index** (its own, or as the leftmost column of a composite index). Postgres does **not** auto-index FK columns. This holds regardless of cascade behavior: an unindexed FK forces a seq-scan of the child table on every parent `DELETE`/`UPDATE` (per-row RI trigger), and the FK column is almost always also a join/filter key. Watch helper-generated FKs — `createJunctionTable` (`src/db/utils.ts`) creates CASCADE FK columns with **no index** on either side.\n\n### Scaffolding\n\n- `npm run generate:component` — interactive generator that can create a service module (DAL + service + types), a standalone DAL, or a router\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## ESLint & Import Ordering\n\nConfig in `.eslintrc.js`. Uses `simple-import-sort` with this group order:\n1. Side-effect imports\n2. `node:` builtins\n3. Third-party packages\n4. `@app/` imports\n5. `@lib/` imports\n6. `@server/` imports\n7. Relative imports\n\n## Architecture\n\n### Service Factory + Manual DI\n\nNo IoC container. Every service is a factory function that receives explicit dependencies as a typed object and returns an object of methods. Dependencies are narrowed with TypeScript `Pick` to define minimal interface contracts.\n\nThe entire dependency graph is manually wired in `src/server/routes/index.ts`:\n- **~line 480-646**: DAL instantiation — each DAL factory receives the `db` client\n- **~line 649-2800**: Service instantiation — each factory receives its DALs + other services\n- **~line 2831-2972**: `server.decorate(\"services\", {...})` exposes ~100+ services as `server.services.*`\n- **~line 3082-3098**: Route registration — EE routes registered before community routes per API version\n\nSee `src/services/secret/secret-service.ts:104-162` for a representative factory with ~20 dependencies. See `src/services/user/user-service.ts` for a simpler example (~8 dependencies).\n\n### DAL Layer (Data Access)\n\nEach service has a `*-dal.ts` that wraps `ormify()` (defined in `src/lib/knex/index.ts:155-326`). `ormify()` provides typed CRUD methods: `findById`, `find`, `findOne`, `create`, `insertMany`, `batchInsert`, `upsert`, `updateById`, `update`, `deleteById`, `delete`, `countDocuments`, `transaction`.\n\nDALs extend these with custom queries — typically complex joins using Knex query builder and `sqlNestRelationships()` for nested entity mapping. All methods accept an optional `tx` parameter for transaction threading.\n\n**Read replica pattern**: Read methods use `db.replicaNode()` (e.g., `(tx || db.replicaNode())(tableName)`), while writes always hit the primary (`(tx || db)(tableName)`). See any `ormify()` method in `src/lib/knex/index.ts` for the pattern.\n\n`updateById` and `update` support atomic `$incr` and `$decr` operators alongside regular field updates.\n\n**A dropped column does not fail type checking if the insert is built in a `.map()`.** TypeScript's\nexcess-property check only fires on a fresh object literal at the assignment site, so a stale field\nsurvives when the literal is returned from a callback:\n\n```ts\ninsertMany(names.map((name) => ({ name, role: \"member\", orgId })));  // compiles, then 500s at runtime\ninsertMany([{ name, role: \"member\", orgId }]);                       // TS2353\n```\n\nSee `src/services/secret/secret-dal.ts` for a DAL that overrides the base `update` to auto-increment version and adds complex join queries.\n\n### Service Module Structure\n\nServices live in `src/services/` (100+ modules). Each typically contains:\n- `*-dal.ts` — data access via `ormify()` + custom queries\n- `*-service.ts` — business logic factory\n- `*-types.ts` — DTOs and type definitions\n- `*-queue.ts` — BullMQ async job handlers and/or cron-manager registrations (when needed)\n- `*-fns.ts` — pure utility functions (when needed)\n\n### Route Handler Pattern\n\nRoutes use Fastify's Zod type provider — schemas auto-generate OpenAPI docs. Each route specifies: `method`, `url`, `config.rateLimit` (using `readLimit` or `writeLimit` presets), `schema` (Zod schemas with `operationId` for OpenAPI), `onRequest: verifyAuth([AuthMode.*])`, and a `handler` that accesses business logic via `server.services.*`.\n\nSee `src/server/routes/v4/secret-router.ts` for a representative router file.\n\n### Auth System\n\nAuth extraction happens in `src/server/plugins/auth/`:\n- `inject-identity.ts` — detects auth mode from request headers and attaches identity to request\n- `verify-auth.ts` — middleware that checks if the request's auth mode is in the route's allowed strategies\n- `inject-permission.ts` — attaches permission context\n- `inject-assume-privilege.ts` — privilege escalation support\n- `superAdmin.ts` — super admin flag injection\n\n**Auth modes** (defined in `AuthMode` enum):\n- **JWT** — user browser sessions (decoded from `Authorization: Bearer` header)\n- **IDENTITY_ACCESS_TOKEN** — machine-to-machine identity tokens\n- **SCIM_TOKEN** — SCIM provisioning tokens\n- **OAUTH** — delegated user tokens from the `oauth-client` module. Same JWT shape and session binding as\n  `JWT`, told apart by an `oauthClientId` claim. See [Delegated OAuth tokens](#delegated-oauth-tokens).\n\n**Deprecated auth modes (do not use in new code):**\n- **API_KEY** — user API keys (from `x-api-key` header). Deprecated — use identity access tokens instead.\n- **SERVICE_TOKEN** — service tokens (Bearer tokens starting with `st.` prefix). Deprecated — use identity access tokens instead.\n\nIf you encounter `API_KEY` or `SERVICE_TOKEN` in existing code, do not replicate them in new routes or services. All new machine authentication should use `IDENTITY_ACCESS_TOKEN`.\n\nToken detection logic in `inject-identity.ts` checks `x-api-key` header first, then parses `Authorization: Bearer` and inspects JWT `authTokenType` field to determine mode.\n\n#### Delegated OAuth tokens\n\nTwo grants issue them: the authorization code flow (consented `scopes`) and the RFC 8693 token exchange\n(`delegation: \"full\"`, no scopes).\n\n**Opting a route in.** `verify-auth.ts` rejects `AuthMode.OAUTH` on any route that doesn't list it; most\nproduct `AuthMode.JWT` routes do. Two things have to hold before adding it. The route must not be an\nadministration route (below). And the handler must actually **check** a CASL ability, because that check\nis where the granted scopes get intersected in: building an ability and throwing it away\n(`getOrgPermission` used as a membership gate), or reading `hasRole(...)` off its return value, narrows\nnothing; `hasRole` is the easy one to miss because it reads like authorization. Rough proxy: nothing\npassing `requireOrg: false` accepts `AuthMode.OAUTH`.\n\n**Administration is read-only for a delegated token.** A delegated token can look at anything the\nauthorizing user can, but it changes nothing under **Administration**: no create, update, or delete, at\neither level. The frontend nav is the boundary, and it is worth opening (`OrgNav.tsx`,\n`OrgSubmenuView.tsx`, `SecretManagerNav.tsx`, `submenus.tsx`) when a new route's side is unclear. The\nsurfaces it covers:\n\n- **org access control** — members, groups, identities (including memberships, templates, and all\n  thirteen `identity-*-auth-router.ts`), org roles, invites, external group-to-role mapping\n- **usage & billing** — `ee/v1/license-router.ts`, `ee/v1/license-v2-router.ts`\n- **audit logs** — the org and project log reads plus audit log streams\n- **org settings** — org update and incident contacts, SSO and provisioning (SAML, OIDC, LDAP, SCIM,\n  GitHub org sync, email domains), networking (gateways, gateway pools, relays), encryption (external\n  KMS, KMIP, KMIP servers, HSM connectors), project templates, sub-orgs, workflow integrations\n  (Slack, Microsoft Teams), and the org-wide EnvKey/Vault migrations on the settings tab\n- **project access control** — memberships for users, groups and identities, project roles, additional\n  privileges, folder grants, project keys, service tokens, and the per-resource equivalents under PAM,\n  cert manager, code signing and PKI applications\n- **project settings** — project update and delete, environments, tags, approval policies, webhooks,\n  trusted IPs, secret validation rules, project KMS, blind index, audit log retention, E2EE upgrade,\n  and the secret scanning `configs` routes\n\nIn practice that means `GET` keeps `AuthMode.OAUTH` and `POST` / `PATCH` / `PUT` / `DELETE` lose it. The\nmethod is the test because `CODE_QUALITY.md` already requires `GET` to be safe, so it needs no per-route\njudgment. Two adjustments to that rule, both small enough to enumerate:\n\n- **A `POST` that only reads keeps it.** Five endpoints are `POST` solely because a lookup needs a request\n  body: `POST /v1/identities/search`, `POST /v2/identities/search`, `POST /v2/identities/search/count`,\n  and the two `memberships/details` routes. All five check a CASL read ability.\n- **A read that hands back credential material loses it**, because the point of closing administration is\n  that a third-party application should not end up holding the org's secrets. Five routes:\n  `GET /v1/sso/oidc/config` (`clientSecret`), `GET /v1/ldap/config` (`bindPass`),\n  `GET /v1/workspace/:projectId/kms/backup` (the project's KMS backup blob), and Slack\n  `GET /install` / `GET /reinstall`, which gate on `OrgPermissionActions.Create` and exist only to start\n  an install. Everything else reads through a sanitized schema — `sanitizedClientSecretSchema` returns a\n  prefix, `sanitizedExternalSchemaForGetById` drops provider credentials, `ScimTokensSchema` carries no\n  token. Check the response schema, not the route name, before opening a new administration read.\n\nThree things are first-party outright, because they sit outside an org and the nav boundary doesn't\nreach them:\n- account self-management (user, password, MFA, sessions, login, signup, notifications, announcements)\n- super-admin routes (`v1/admin-router.ts`), where `verifySuperAdmin` reads\n  `user.superAdmin` rather than an ability\n- `POST /v2/organizations`, and the org reads that return more than the token's org: `GET /v1/organization`,\n  `GET /v1/organization/accessible-with-sub-orgs`, `GET /v1/organization/:organizationId`. All pass\n  `requireOrg: false`, so nothing narrows them\n\nEverything else that used to be listed here individually — OAuth client CRUD, assume-privilege, deleting\nan org or sub-org, minting SCIM tokens, KMIP client certificates or gateway / relay / KMIP-server\nenrollment tokens, registering identity auth methods — is an administration write, and closed by the rule\nabove rather than by its own exception. Two of those reasons still generalize, so keep them in mind when a\n**product** route does the same thing:\n\n- **A route that leaves behind something able to authenticate later can't accept a delegated token.**\n  Revocation reaches only the sessions tagged with `getOauthClientSessionUserAgent`, so a credential or an\n  auth method bound to a key the caller holds survives deleting the application and rotating its secret,\n  and authenticates as a principal no OAuth scope intersects. Whether the route hands secret material back\n  is beside the point, so start any new route that writes a trust anchor (bound issuer, signing key, CA,\n  cloud principal) first-party.\n- **Nor can a route that reissues the caller's session.** `DELETE /v2/organizations/:organizationId`\n  returns a fresh pair from `loginService.generateUserTokens`, which takes no delegation parameter, so a\n  delegated caller would exit with a first-party session.\n\n**Delegation markers.** A token carries `scopes` or `delegation: \"full\"`, never both\n(`oauth-client-types.ts`). `permission-service` intersects the user's CASL rules with `scopes`\n(`applyOauthScopeToOrgRules` / `applyOauthScopeToProjectRules`). `inject-identity.ts` sets\n`RequestContextKey.OauthScopes` for every scope-narrowed token (even `[]`, which denies everything) and\nleaves it unset only for full delegation. Matching full delegation **positively** is what makes a dropped\nclaim fail closed instead of promoting the token.\n\n**The token endpoint's error contract is RFC 6749 §5.2, not the house envelope**, because generic OAuth\nlibraries branch on the `error` code. `OauthTokenError` (`oauth-token-error.ts`) carries the code and\nderives the status from it (400; 401 for `invalid_client`, 500 for `server_error`), and the shared error\nhandler renders `{ error, error_description }`. When extending it:\n- The handler's `try`/`catch` is the endpoint's only exit, and every `return` inside it is **awaited**: a\n  bare `return promise` would resolve outside the `catch` and escape in the house envelope.\n- `toOauthTokenError` defaults by **error class**, never message text: `UnauthorizedError` and\n  `ForbiddenRequestError` take the rejected-grant code for that grant (`invalid_grant` on the redirect and\n  refresh grants, `invalid_request` on token exchange, per RFC 8693 §2.2.2), `BadRequestError` takes\n  `invalid_request`, and anything else becomes `server_error`, whose response carries no detail and whose\n  original is logged. Both refusal classes are listed because the exchange rejects an unusable subject in\n  two vocabularies: `exchangeSubjectToken`'s own membership checks raise `UnauthorizedError`, while\n  `getOrgPermission` raises `ForbiddenRequestError` for the same kind of refusal. A new way to refuse a\n  subject belongs in one of those two classes, or it lands in `server_error` as an unexplained 500.\n- Throw `OauthTokenError` directly where that default is wrong: client authentication\n  (`invalid_client`), a grant the client doesn't hold (`unauthorized_client`), a redirect-URI or PKCE\n  mismatch (`invalid_grant`), the unsupported `scope` (`invalid_scope`) and `audience`/`resource`\n  (`invalid_target`) parameters, and org misconfiguration or IdP outages (`server_error`).\n- The route sets `attachValidation` so a schema failure reaches the handler; an unrecognized `grant_type`\n  earns `unsupported_grant_type`.\n- `toErrorDescription` sanitizes and bounds `error_description` at the render boundary, so no throw site\n  has to remember RFC 6749's printable-ASCII restriction.\n- Messages shared with a management route (the OIDC-config checks) take an error factory rather than\n  throwing, since the two callers owe the same explanation in different envelopes.\n\n**Token exchange trusts the org's OIDC SSO config, not per-client configuration**\n(`oauth-token-exchange-fns.ts`), so the issuers that can vouch for a user are the ones that can already\nlog them in. The one per-application field is `tokenExchangeAudience`, and it does the security work:\nwithout it, any token that issuer signed for anything in the estate is exchangeable. Enabling the grant,\nchanging the audience, and rotating the secret each need `OrgPermissionSsoActions.Edit` on top of the usual\n`OauthClients` check (`checkSsoConfigPermission`), rotation included, since its response is a working\ncredential for acting as any of the org's users.\n\n`getOrgPermission` is **not** a membership check: the org-scope query in `permission-dal.ts` never reads\n`Membership.isActive` or `Membership.status`. Every other user token came from a browser login, where\n`selectOrganization` rejects an inactive membership; the exchange has no login in front of it, so\n`exchangeSubjectToken` checks membership state itself. Any future non-interactive way of minting a user\ntoken needs the same check.\n\n**Withdrawing an exchange application's authority revokes the tokens it already issued.** Deleting the\nclient, rotating its secret, and any update that narrows the exchange's trust (dropping the grant,\nchanging `tokenExchangeAudience`, switching `tokenExchangeIdpSatisfiesMfa` off) all call\n`revokeSessionsByUserAgent`. `hasWithdrawnTokenExchangeTrust` and `hasClientAuthorityChanged` guard the\nsame fields, so a new field belongs in both. The test is whether it carries federation trust, not merely\nwhether it can be narrowed. `accessTokenTTL` is in neither: it only decides how long a new token lasts, so\nrevoking on a routine edit would surprise, and guarding it would fail in-flight exchanges. Widening\n(turning the MFA declaration on) revokes nothing. Rotation revokes for exchange clients only, where the\nsecret alone mints tokens; in the redirect flow it has to be paired with a code or refresh token, so\nblanket revocation would just sign everyone out. The session tag is per client, which is unambiguous\nbecause `assertValidOauthClientGrantConfig` rejects the exchange grant alongside `authorization_code`, so\na service needing both registers twice.\n\nThat sweep only reaps sessions that already exist, so **the exchange rechecks the client after creating\nits own session** (`findByIdForUpdate` + `hasClientAuthorityChanged`, re-running the sweep). The other\ngrants read an existing session rather than creating one, so they don't need it. The recheck has to be a\n**locking** read on the primary, and both halves of that are load-bearing: each withdrawal path writes the\nclient and sweeps sessions inside one transaction, so a plain read still sees the pre-withdrawal row until\nthat transaction commits, and would clear a token whose session the sweep has already scanned past. A\nreplica read has the same hole for the length of the replication lag. Keeping the write and the sweep\natomic is what makes the lock the fix here; splitting them so the write commits first would reopen a\nwindow where the sweep can fail on its own and leave the tokens live.\n\n**Everything the exchange fetches from the IdP is cached for 10 minutes; nothing read from our own\ndatabase is**, so an admin editing the SSO config takes effect on the next request while the provider\nnever becomes a synchronous dependency of the middleware's own request path. The cache invariants\n(rejections evicted, in-flight promise shared, expiry rebuilding the SSRF-pinned agent) are documented in\n`oauth-token-exchange-fns.ts`.\n\n### Resolving Users From Provisioning Identifiers\n\nEvery invite / removal path resolves a person by `users.username` (which always equals their email),\nnever by `users.email` and never by a join. An external provisioning system, though, often knows\npeople only by the identifier its IdP asserts, a UPN like `m249913@one.example.com` rather than the\nmailbox `robert@example.com`. SSO login already records that identifier as\n`user_aliases.externalId`, so the two are reconcilable without making `username` diverge from\n`email`.\n\n`resolveUsersBySsoExternalId` in `src/services/user-alias/user-alias-fns.ts` is the one place that\ndoes it, backed by `userAliasDAL.findBySsoExternalIds`. Callers run it **only after an exact\n`username` lookup has missed**, so an alias can never shadow a real account. It is wired into\n`$getUsers` (`membership-user-service.ts`, the single funnel for org, project, and cert-manager\ninvites), `project-membership-service.ts` (removal and membership read-back), and PAM's\n`addProductUserMembers`.\n\nFour invariants, each load-bearing:\n\n- **Scope by `orgId` *and* `aliasType`.** `user_aliases.orgId` is NULL for the global\n  google/github/gitlab aliases, so `whereIn(\"orgId\", ...)` excludes them outright and the\n  `ORG_SCOPED_USER_ALIAS_TYPES` filter is the second lock. Without both, an org admin could name a\n  user in another tenant.\n- **Match `externalId` exactly, never folded.** It is a case-sensitive identifier (OIDC Core defines\n  `sub` that way, as does SAML for nameID) and is stored verbatim, so folding case could collapse two\n  distinct IdP subjects onto one identifier. The known consequence: because both invite routes\n  lowercase their input (`sanitizeEmail`, and the `.refine` on `usernames`), an IdP that asserts\n  mixed-case identifiers cannot be provisioned against at all. Supporting those means relaxing that\n  input validation so the exact identifier survives to the query, not loosening the comparison.\n  `adoptProvisionedShadowUser` does not fold either. It derives its lookup key with `sanitizeEmail`\n  because it searches the `users.username` namespace, where lowercase is canonical, but it then\n  **refuses any identifier that is not already canonical** rather than adopting on the folded match.\n  It once did adopt, and that was a real hole: a subject differing only by case is a different\n  subject, so it could take over the placeholder provisioned for another one and inherit its grants.\n  Folding bought nothing anyway, since the alias written afterwards is verbatim and this exact-match\n  lookup could never find it again, stranding the grant where provisioning cannot manage it.\n- **Ambiguity is an error.** Nothing constrains `(externalId, aliasType)` to be unique for the\n  org-scoped types (only the social ones have a partial unique index), so an identifier can reach two\n  users. Picking one would be a guess about which human it names, and the cost of guessing wrong is\n  granting access to the wrong person. Several aliases on the *same* user are not a conflict.\n- **Dedupe resolved users by `id`.** An identifier now reaches a user by either their username or\n  their alias, so one request can name the same person twice. Undeduped, that violates\n  `membership_unique_user_org` and surfaces as a 500.\n\nNothing constrains a user to one alias per `(orgId, aliasType)`, and SSO login mints a new one\nwhenever the asserted subject differs from what SCIM last wrote, so **a SCIM read must match a\n`userName` against every one of a user's aliases, not the newest**. Matching only the newest made a\nprovisioned user vanish from `GET /Users?filter=userName eq \"...\"` the first time they logged in\nunder a different subject, and the IdP answered that empty lookup by provisioning them again. It was\nintermittent because `replaceScimUser` rewrites `externalId` on *all* of a user's aliases, so the\nnext PUT healed it until the next login. `$buildScimMembershipQuery` (`org-dal.ts`) therefore\nanswers every `userName` comparison with an `EXISTS` over the user's aliases, so the predicate is per\nuser rather than per alias row. That matters for negation: evaluated per row, `userName ne \"x\"` or\n`not (userName eq \"x\")` would keep a user through their other alias, and a member with no alias at\nall would fall out through a NULL comparison. The parser (`lib/knex/scim.ts`) lets an attribute\nresolve to a handler instead of a column for exactly this. Display is separate: the query still\njoins every alias and collapses the fan-out with `DISTINCT ON`, ranked so the alias row that\nsatisfies the filter wins and the newest is the fallback, which is how a lookup by an older alias\nechoes that alias back. The list query also carries a total order, because an IdP walking\n`startIndex`/`count` over an unordered result loses users the same way.\n\nA related case sits on the login side: provisioning can name someone before they have ever logged\nin, leaving a placeholder account keyed on the identifier instead of the mailbox.\n`adoptProvisionedShadowUser` (same file, wired into `oidcLogin`'s no-alias branch) adopts that row\nand rewrites it to the asserted mailbox rather than creating a second account. It refuses anything a\nhuman has claimed (accepted, email-verified, holding a password), anything already bound to an IdP\n(any alias, any org), ghosts, anything whose identifier is not already canonical (see above), and\nanything without a membership in the org doing the login.\n\nOne case is a refusal to *log in* rather than a refusal to adopt: a placeholder whose membership in\nthe login org is **inactive**. Declining is not neutral there, because the caller reads a `null` as\n\"no placeholder\" and creates a second account with a fresh active membership, handing a deactivated\nperson their org back (`selectOrganization` then accepts it). So the inactive check is resolved\nbefore every remaining decline — the alias check and the cross-tenant check both sit after it — and\nit throws the same `ForbiddenRequestError` an already-aliased deactivated member gets.\n\nIt also refuses a placeholder that holds an org membership **outside** the login org's own sub-org\nfamily, and that one is the security-critical check rather than a tidiness one. The username lookup\nthat finds the placeholder is global, so a second tenant that invited the same identifier shares the\nrow; adopting it would hand the login org's IdP subject that tenant's memberships, and\n`selectOrganization` accepts a membership in any status (promoting `Invited` to `Accepted` on\narrival), so nothing downstream stops the inherited access from being used. A project membership\nalways implies an org membership in the same org, so the org-scope check covers project access too.\n\nAdoption also recovers from a unique violation on `users.username`, because the caller's preceding\nread is not a lock. That recovery has to run inside a savepoint (`tx.transaction()`, which knex\ncompiles to `SAVEPOINT`/`ROLLBACK TO SAVEPOINT` on the same connection): Postgres aborts the whole\ntransaction on a constraint violation, so an unscoped retry would fail with `25P02`, taking the\ncaller's remaining alias and membership writes with it. SAML and LDAP have structurally identical\nbranches and are deliberately not wired up.\n\nBecause adoption rewrites an existing account's `username` and `email`, it emits an\n`OIDC_PROVISIONED_PLACEHOLDER_ADOPTED` audit event carrying the before and after, not just an\napplication log: if one of the refusal checks above ever regresses, the audit trail is what makes it\nfindable. That event must never fire on the unique-violation recovery path, where the returned user\nis whoever won the race rather than a rewritten placeholder. `adoptProvisionedShadowUser` draws that\nline by returning `adoptedFromUsername: null` for the yield, and the caller keys the audit log on\nit.\n\nThat trail is best-effort, not guaranteed. `audit-log-queue.ts` drops every entry at push time when\n`plan.auditLogsRetentionDays` is 0, which is the default for a self-hosted instance with no\naudit-log entitlement, so on those deployments only the `logger.info` line survives an adoption.\n(`null` is the License Server's \"No limit\" and keeps everything; see `audit-log-retention-fns.ts`.)\nDo not special-case this event past the retention gate; treat the application log as the floor and\nthe audit event as the addition for licensed instances.\n\n### Profile Sync From The IdP (SSO-enforced orgs)\n\nAn IdP keyed on a stable identifier lets someone change mailbox and display name without changing\nwho they are, so our copy of both goes stale: notification mail goes to an address that no longer\nexists, and every audit entry written from then on records it. `syncSsoUserProfile`\n(`src/services/user-alias/user-alias-fns.ts`) carries the asserted email and name onto the account,\nand is called from all three login services right after `ensureSsoAccountVerified`, outside the\ncaller's transaction, with its result flowing into the session.\n\nThe gate is `organization.authEnforced` **and** a verified alias, and both halves matter. Enforcement\nis the org saying the IdP is authoritative for identity, which is the same claim that already skips\nemail verification at signup. The verified alias is the proof that the IdP controls *this* account:\nan unverified alias asserting an unrecognized email is exactly what `isStaleSsoAlias` exists to\ncatch, and reading that as a rename would let a stale alias rewrite somebody else's account. One\nconsequence to know: a legacy unverified alias whose email changed before its next login is stale,\nso it gets the email-verification fallback (a code sent to the dead mailbox) rather than a sync.\n\nThree invariants:\n\n- **It never fails a login.** A rename that could not be applied leaves stale data, which is what we\n  already had; a throw locks the person out of an org that has no other way in. Every failure path\n  returns the unchanged user.\n- **It only renames an address the org owns, onto another address the org owns.** Both halves are\n  checked in the helper against the org's verified domains. The user row is global rather than\n  org-scoped, so without the first half an org could rename an account that merely carries one of\n  its aliases and rewrite the identity every other org of that user sees. Every login path\n  establishes both already (`verifyEmailDomainOwnership` is a positive check: the domain must be\n  verified *for this org*, and the alias branch runs it against the existing account's username),\n  so the helper's copy is there to keep the rename from outliving those checks. It is unreachable\n  today, and audits the skip anyway (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"domain-not-owned\"`) so a refactor that removes a caller's check leaves a trail rather\n  than a silently stale account.\n- **It never renames onto an occupied address.** `users.username` is globally unique and the row is\n  global rather than org-scoped, so the asserted address may already be a personal signup or an\n  unmerged duplicate. The conflict is recorded (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"address-taken\"`) and the email is left alone; the name still syncs. The preceding read\n  is not a lock, so a unique violation lands on the same path.\n\nSCIM is the other half of the same story and moves with it. `updateScimUser` / `replaceScimUser`\nrejected every email change outright, which left the data stale *and* put the provisioning job in a\npermanent error state (Entra quarantines after repeated failures). Both now accept the change under\nthe same `authEnforced` gate via `$resolveScimEmailChange`, and answer an occupied address with\n`409 uniqueness` rather than a constraint-shaped 500. Self-service email change\n(`user-service.ts`) is refused when the next login would overwrite it anyway, which\n`$getManagedEmailReason` narrows to the case that actually would: the account's own address sits on a\ndomain one of its SSO-enforced orgs has verified. Membership in an enforced org is not the test, since\nthe user row is global and an address outside that org's domains is one `syncSsoUserProfile` will not\ntouch. SCIM stays a blanket refusal, because the directory provisions the address either way.\n\n### Permission System (CASL)\n\nUses CASL (`@casl/ability`) with MongoDB-style rules. Permission logic lives in `src/ee/services/permission/`:\n- `permission-service.ts` — factory that builds CASL abilities from user/identity roles\n- `project-permission.ts` — defines project-level permission actions and subjects\n- `org-permission.ts` — defines org-level permission actions and subjects\n\n**Project permission actions** include standard CRUD plus specialized ones like `DescribeSecret` (see metadata without value), `ReadValue`, `GrantPrivileges`, `AssumePrivileges`, `Lease` (for dynamic secrets). See `ProjectPermissionActions`, `ProjectPermissionSecretActions`, `ProjectPermissionDynamicSecretActions`, and `ProjectPermissionIdentityActions` enums in `project-permission.ts`.\n\nBuilt-in roles: `Admin`, `Member`, `Viewer`, `NoAccess`. For PAM and Agent Vault `getPredefinedRoles` (`project-role-fns.ts`) returns only `Admin` and `Member`, because their permission dispatch resolves every other slug to the member set; the role factory delegates to that one function, so every role picker follows. Custom roles use unpacked CASL rules stored in the database. Rules can include conditions with operators `$IN`, `$EQ`, `$NEQ`, `$GLOB` (for pattern matching like `prod-*`). See `PermissionConditionSchema` in `permission-types.ts`.\n\n**Privilege boundaries mean different things on the two privilege systems.** `organizations.shouldUseNewPrivilegeSystem` (default `true`, backfilled `false` for orgs predating March 2025) picks which. `validatePrivilegeChangeOperation` / `assertRoleSetBoundary` (`permission-fns.ts`) are the shim: on the new system they collapse to `actorPermission.can(action, subject)`, on the legacy one they run `validatePermissionBoundary`, requiring the actor to out-rank every role the target holds. So a boundary at a call site that already gated on the same action is a no-op for new-system orgs and the only protection for legacy ones. Two consequences: \"the route checks the action\" is not a substitute for a boundary, and adding one is not a behavior change for most tenants. Prefer `getOrgPermissionByRoles` / `getProjectPermissionByRoles` + `assertRoleSetBoundary` over fetching the target's merged ability. It bounds each role separately, and it avoids `getProjectPermission`, which stamps whichever actor it was called for onto the request context the audit log reads.\n\n**Identity auth-method access goes through `assertIdentityAuthAccessAllowed`** (`src/services/identity/identity-auth-permission-fns.ts`), because repointing an identity's auth trust lets the caller authenticate as that identity. It's both the gate and the boundary: it checks the identity action itself on both privilege systems, and on legacy also makes the actor out-rank every grant the target holds. The boundary alone isn't enough, since it passes for any target you cover, and for an org identity it only sees org-scope grants, so a no-access org identity that's admin in projects would be open to anyone. Callers don't repeat the action check. The 13 auth services and the template update call it once per attach/update/revoke, outside the `identity.projectId` branch so both scopes are covered, and a new auth method should too. Only the fine-grained action gates these paths, at both scopes; the old coarse org checks (`edit`, `create`, `read`, `delete`) are gone. Credential issuance (`createTokenAuthToken`, `createUniversalAuthClientSecret`) goes through it under `create-token`, since minting a credential is the same escalation as repointing the trust, and so do the per-credential paths: `updateTokenAuthToken` under `create-token`, `getUniversalAuthClientSecrets` / `getUniversalAuthClientSecretById` under `get-token`, and `revokeUniversalAuthClientSecret` under `delete-token`. **`PROJECT_ACTION_BY_ORG_ACTION` must map each org action to its project equivalent**, since project identities get checked against the mapped one. `revokeTokenAuthToken` and `clearUniversalAuthLockouts` are still unbounded in both scopes (and the former gates on `edit` where its UA counterpart gates on `delete-token`); that predates the helper and is a deliberate gap, not an oversight to copy. The target's grants come from `permissionService.getActorGrantAbilities`, not from a membership lookup, and it returns one ability per grant rather than per role. Two things a role-slug resolver misses: a group-derived membership carries a NULL `actorIdentityId`, so `membershipIdentityDAL.getIdentityById` never sees it, and an additional privilege carries a raw permission blob with no slug at all, so no `*PermissionByRoles` path can express it. Either omission clears an actor that out-ranks the target's direct roles but not its effective access. `getActorGrantAbilities` reads the same `permissionDAL.getPermission` query the ability itself is built from, so the bounded set cannot drift from the effective one.\n\n**Auth template propagation is serialized with row locks on `identity_auth_templates`.** A template edit rewrites every linked identity's auth, and a link copies the template's values onto the identity row, so the two must not interleave: a link landing between the edit's permission loop and its writes would be rewritten by an actor never checked against it, and an edit landing between a link's validation and its insert would leave the identity holding values the template no longer has. `updateTemplate` and `deleteTemplate` take the row `FOR UPDATE` as the first statement of their transaction, and the edit re-reads usages through `tx` under that lock and returns a 409 if an identity appeared since the checked set was built; its propagation writes are also scoped to the checked ids so the privilege invariant does not rest on the lock. Every path that copies template values onto an identity (the three attaches, and the three identity updates when they load a template) calls `assertTemplateUnchangedForLink` (`ee/services/identity-auth-template/identity-auth-template-fns.ts`) as the first statement of its transaction: a `FOR SHARE` read plus an `updatedAt` comparison against the row it validated outside the transaction, 409 on mismatch. Lock order is template row first, identity row second; unlink and non-linking identity updates take no template lock and never wait on it, so no cycle is possible. Nothing slow may sit under either lock, which is why LDAP attach runs `testLDAPConfig` before its transaction opens. A new path that copies template values onto an identity must call the helper inside its transaction.\n\n**Deny an RBAC check by throwing through CASL, never with `ForbiddenRequestError`.** `audit-log-permission-denied.ts` records a permission-denied audit event for every CASL `ForbiddenError` and `PermissionBoundaryError` a request throws, and skips plain `ForbiddenRequestError` because most of those are auth-mode, plan and ownership refusals. A service that calls `permission.can()` and throws `ForbiddenRequestError` itself therefore produces no denial event, and nothing fails to tell you. When the check has a fallback (a second action, an approver, an application grant), test the fallbacks first and make `ForbiddenError.from(permission).throwUnlessCan(...)` the last check, so the denial is recorded with a real ","isInternal":false,"tokens":11768,"sizeBytes":50116},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Essential Commands\n\n- `make reviewable-api` / `make reviewable-ui` — lint:fix + type:check (run before PRs). Neither checks [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md); review backend changes against it yourself.\n- `cd backend && npm run migration:new` — create new DB migration\n- `cd backend && npm run generate:schema` — regenerate Zod types from DB after migration changes\n- `make test-api-unit` / `make test-api-e2e` — run the Node backend suites in the FIPS image CI uses, against a throwaway database. Narrow with `SPEC=<pattern>`. See `backend/CLAUDE.md` for why running `npm run test:e2e` directly can destroy your dev data.\n- `cd backend-go && make test` — run Go integration tests\n\nBoth backend and frontend use `@app/*` as path alias to `./src/*`.\n\n## Repository Structure\n\nInfisical is an open-source secret management platform. Monorepo layout:\n\n```\ninfisical/\n├── backend/               # Fastify 4 API server (see backend/CLAUDE.md)\n├── backend-go/            # Go API server — partial rewrite (see backend-go/CLAUDE.md)\n├── frontend/              # React 19 SPA (see frontend/CLAUDE.md)\n├── wasm/                  # Rust crates compiled to WASM for the frontend (see wasm/<crate>/CLAUDE.md)\n├── e2e/                   # External Playwright suite — gates prod deploys against gamma (see e2e/CLAUDE.md)\n├── tests/                 # Go blackbox suite — real containers, HTTP only (see tests/CLAUDE.md)\n├── docs/                  # Documentation site (Mintlify-based)\n├── build-versions.env            # Versions pinned across several Dockerfiles (see Dependency Policy)\n├── docker-compose.dev.yml        # Local dev (PostgreSQL, Redis, backend, frontend, Nginx)\n├── docker-compose.prod.yml       # Production deployment stack\n├── docker-compose.bdd.yml        # BDD testing environment\n├── docker-compose.e2e-dbs.yml    # E2E test databases (Oracle, SAP, Snowflake, etc.)\n├── Dockerfile.standalone-infisical       # Standalone image (frontend + backend)\n├── Dockerfile.fips.standalone-infisical  # FIPS-compliant standalone image\n├── .github/               # CI workflows, PR template\n└── CLAUDE.md               # This file\n```\n\n- **`backend/`** — Fastify 4 API server, TypeScript, PostgreSQL via Knex, BullMQ queues. See [`backend/CLAUDE.md`](backend/CLAUDE.md) for architecture, patterns, and commands.\n- **`backend-go/`** — Go API server (partial rewrite), chi + chita framework, raw pgx queries, same PostgreSQL database. See [`backend-go/CLAUDE.md`](backend-go/CLAUDE.md) for architecture, patterns, and commands.\n- **`frontend/`** — React 19 SPA, Vite 8, TanStack Router + React Query, Tailwind CSS v4. See [`frontend/CLAUDE.md`](frontend/CLAUDE.md) for architecture, patterns, and commands.\n- **`wasm/`** — Rust crates that compile to WASM for the frontend. Generated bindings are committed under `frontend/src/lib/<crate>/` so the frontend builds without a Rust toolchain. Each crate has its own `CLAUDE.md` with the rebuild command (e.g. [`wasm/ironrdp-decoder/CLAUDE.md`](wasm/ironrdp-decoder/CLAUDE.md)) — run it after any change to that crate's `src/` or `Cargo.toml` so source and bindings stay in sync.\n- **`docs/`** — Product documentation site. Has its own Dockerfile for building. Reference docs for up-to-date feature descriptions and API usage.\n- **`e2e/`** — Playwright suite that runs against a deployed environment (gamma) between deploy and prod promotion. Distinct from `backend/e2e-test/` (in-process Vitest). Failure blocks every prod-deploy job. Covers SCIM + SAML flows (SP-initiated, IdP-initiated, deactivation, response rejection) against a mock IdP we control — see [`e2e/CLAUDE.md`](e2e/CLAUDE.md) for the harness and the one-time gamma bootstrap.\n\n- **`tests/`** — Go blackbox suite. Boots a real Infisical in Docker and talks to it over HTTP only, so the same tests run against the Node and Go servers unchanged. Distinct from `backend/e2e-test/` (in-process Vitest, compiled against the Node source) and from `e2e/` (Playwright against a deployed gamma). `make test-suites` from `tests/` runs everything; see [`tests/CLAUDE.md`](tests/CLAUDE.md) before writing one.\n\nEnterprise features live in `backend/src/ee/` (services and routes), registered before community routes so they can override/extend them.\n\n### Self-Hosted Deployment\n\nInfisical supports self-hosted deployment via Docker. Key considerations:\n- **`Dockerfile.standalone-infisical`** — single-container image with both frontend and backend; used for simple deployments.\n- **`Dockerfile.fips.standalone-infisical`** — FIPS 140-2 compliant variant for regulated environments. Be strict about not introducing dependencies that break FIPS compliance.\n- **`docker-compose.prod.yml`** — production compose with backend, PostgreSQL, and Redis.\n- New backend dependencies should be evaluated carefully — they affect container size, FIPS compliance, and the encryption boundary. Check `docs/` for self-hosted deployment documentation when in doubt.\n\n### Dependency Policy\n\n`build-versions.env` holds versions that several Dockerfiles install, currently the bundled\nInfisical CLI. Each Dockerfile keeps a matching `ARG` default so external builders (Northflank,\nRender, a plain `docker build`) work without passing anything, and CI and compose override it from\nthat file. `check-dockerfile-pins.yml` fails a PR when the defaults drift from it, so change the\nfile and the `ARG` defaults together.\n\nBoth `backend/` and `frontend/` enforce a minimum release age of 7 days for npm packages (configured via `.npmrc` in each directory). This means `npm install` will only resolve package versions published at least 7 days ago, as a supply-chain security measure.\n\nThe backend installs with the npm bundled in Node 26 (11.x), which skips dependency install scripts unless `allowScripts` in `backend/package.json` approves them. The install still succeeds when a script is skipped, so a native addon that was not approved fails only when it is first `require`d. When adding or bumping a dependency with an install script, run `npm install-scripts ls` and approve or deny what it lists (`npm install-scripts approve <pkg>`).\n\n## Cross-Cutting Patterns\n\n### Backend Code Quality\n\n**Read [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md) for every backend change, and check the change against it before calling the work done.** This applies to all work under `backend/`: new features, refactors, bug fixes, and reviews alike.\n\nIt is a floor, not an exhaustive standard: user-understandable error messages and no pointless 500s, explicit validation on every API input, correct pagination when calling third-party APIs, avoiding deadlock conditions on a small connection pool (thread `tx`, keep transactions short), REST-aligned API interfaces (flag deviations for the author to confirm rather than implementing them silently), and audit log events an admin can actually read.\n\nThat list describes what the guide currently covers; it is **not** a test for whether the guide applies. Do not skip it because a change does not look like one of those topics. Read it, then decide which items are relevant.\n\n### Code Comments\n\n**Default to no comments.** One earns its place only by explaining *why*: a non-obvious constraint, a workaround, an ordering dependency, or logic that looks wrong until you know the reason.\n\nNever write: narration restating the next line; section headers inside a function (`// --- validation ---`); change history (`// Added retry logic`, `// NEW`); references to plans, tickets, PRs, or reviewers; docstrings restating the signature; commented-out code.\n\nBefore finishing, delete any comment you added that only says what the code says.\n\n### Product Analytics\n\nRead [`ANALYTICS.md`](ANALYTICS.md) before adding or changing product analytics. Every event has one owning producer: the frontend records UI exposure and intent, the application backend records accepted commands it can establish, and the system of record records durable outcomes. Define stable dimensions, cardinality, firing conditions, and dashboard consumers before implementation.\n\n### Design System & Voice\n\nThe v3 visual system (colors, typography, components, layout) and product voice/content tone are documented in [`DESIGN.md`](DESIGN.md). Read it before producing new UI or user-visible copy.\n\n### Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md): what belongs on a page, and the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/`. It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide: sentence case in headings and frontmatter titles, product and vendor spellings, spelling against a curated vocabulary, `$` prompts in code blocks, and em dash density. See [docs/CONTRIBUTING.MD](docs/CONTRIBUTING.MD) for how to extend the vocabulary or suppress a rule. It lints only the `.mdx` files the branch touched; `make lint-docs` checks every page. The `Check docs style` GitHub workflow runs that same script, so local and CI agree by construction. Every rule except `Infisical.EmDashes` reports at error level, including `Infisical.UIActions` (click/tap where the verb should be select) and `Infisical.Contractions`, so the run fails if either one reports a finding. `Infisical.EmDashes` reports at warning level and never changes the exit code -- read the printed output, not just the status. Note that Vale cannot see prose indented four or more spaces inside components -- roughly half of this repo -- and reports nothing about what it skipped, so a clean run does not mean a nested page was checked.\n\n### Auth & Permissions\n\nAuth modes (JWT, IDENTITY_ACCESS_TOKEN, SCIM_TOKEN) are extracted in `backend/src/server/plugins/auth/`. Authorization uses CASL (`@casl/ability`) with project-level and org-level permission checks — see `backend/CLAUDE.md` for backend details and `frontend/CLAUDE.md` for frontend permission hooks/HOCs. Note: `API_KEY` and `SERVICE_TOKEN` auth modes are deprecated — do not use them in new code.\n\n### Org-Scoped Products\n\nPAM and Agent Vault are products over one implicit project per organization, not projects a user creates. Their URLs are `/organizations/$orgId/<product>/…` with no `$projectId`, the frontend resolves the project from the org (`useImplicitProjectId`), the backend bootstraps it lazily and blocks generic create and delete, their roles are admin or member only, and each has its own metered identities dimension (`pam_identities`, `agent_vault_identities`). When you add a `ProjectType.PAM` arm anywhere, add the Agent Vault arm beside it. See `backend/src/ee/services/pam/CLAUDE.md` and `backend/src/ee/services/agent-vault/CLAUDE.md`.\n\n### Single-Project Products\n\nSecret Scanning has one active project per organization (the newest). Like Certificate Manager, its URLs are `/organizations/$orgId/secret-scanning/…` with no `$projectId`; legacy orgs can still hold several projects, so the frontend pins the one being viewed (see `frontend/CLAUDE.md`). New orgs and sub-orgs get it at creation (`bootstrapSecretScanningProject`), older orgs lazily from `GET /api/v2/secret-scanning/project`, and `projectId` is optional on its API (`inject-secret-scanning-project-id`). Do not add a project picker or a way to create more Secret Scanning projects.\n\n### Service Factory + Manual DI (Backend)\n\nNo IoC container in either backend. Every service is a factory function with explicit dependencies.\n- **Node.js**: Wired in `backend/src/server/routes/index.ts` — see `backend/CLAUDE.md`.\n- **Go**: Wired in `backend-go/internal/server/api/api.go` via `NewRegistry()` — see `backend-go/CLAUDE.md`.\n\n### Interface Pattern (Go)\n\nBoth handlers and services define narrow interfaces for their dependencies (consumer-defined interfaces). Only expose methods or fields that are needed — keep everything else private. This enables testability and loose coupling.\n\n### Alerting\n\nAll user-facing \"notify me when X happens\" features share one module: `backend/src/services/alert/`. It owns the alert CRUD, the channel stack (email, Slack, webhook, PagerDuty), recipients, dedup, history, and dispatch. To alert on a new resource, register an `IResourceAlertProvider` on the shared registry — do not stand up a per-domain alert service, channel table, or notification cron. See `backend/CLAUDE.md` for the provider contract and invariants.\n\n**If you touch a code path that deletes or detaches an alertable resource, it must reap that resource's alerts.** `alerts.resourceId` has no foreign key, so nothing cascades and the alert is left dangling. Use `alertService.deleteAlertsForDeletedResource` when the row is gone (unscoped, reaps across every org) and `deleteAlertsForResource` when the resource only left a scope. See the alerting invariants in `backend/CLAUDE.md`.\n\n### Approvals\n\nAll \"someone has to approve this before it happens\" features share one module:\n`backend/src/services/approval-policy/`, which owns policies, requests, steps, grants, break-glass, and\nthe generic `/v1/<policy-type>/approval-policies` endpoints. Current types: PAM access, certificate\nrequests, code signing.\n\nA policy type plugs in through **one** extension point, a `TApprovalResource` (see its doc comment),\nconstructed with its own dependencies and registered in the `approvalResources` map in\n`server/routes/index.ts`; that also gives it the generic `/v1/approval-policies/<type>/...` endpoints,\nwhich every type now uses for policy CRUD, requests, approve, reject and grant revocation. **Do not stand up a per-product approval service, request table, or review\nendpoint**: `access-approval-*` and `secret-approval-*` predate this module and are what we are\nconverging away from. A product-facing service on top of it (PAM's `pam-access-request-service.ts`) is\nfor product vocabulary only: resolving the resource a request names, notifications, and read models.\nAny rule about what is *allowed* belongs in the resource, or the product's own API and the shared API\nstop agreeing.\n\n### API Layer (Frontend)\n\nReact Query + Axios with query key factories per domain. Each API domain in `frontend/src/hooks/api/` has `queries.tsx`, `mutations.tsx`, and `types.tsx` — see `frontend/CLAUDE.md` for conventions.\n\n## Keeping CLAUDE.md Up to Date\n\nWhen making significant changes to the codebase (new services, architectural shifts, new patterns, major refactors), update the relevant CLAUDE.md file(s) with high-level findings. This includes this root file for cross-cutting concerns, `backend/CLAUDE.md` for Node.js backend changes, `backend-go/CLAUDE.md` for Go backend changes, and `frontend/CLAUDE.md` for frontend changes. The goal is to keep these files accurate as living documentation so future sessions start with correct context.\n\n## Wiring a New Full-Stack Feature\n\n1. **Backend**: Create service module, migration, wire DI, add routes — see checklist in `backend/CLAUDE.md`\n2. **Frontend**: Add API hooks in `src/hooks/api/<domain>/`, create page/view, wire route — see `frontend/CLAUDE.md` for routing and component patterns\n3. Check the backend work against [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md)\n4. Run `make reviewable-api` and `make reviewable-ui` before submitting\n\n## Helpful files\n\nClaude Code reads CLAUDE.md, not AGENTS.md, so the shared agent instructions are imported here rather than linked:\n\n@AGENTS.md\n","isInternal":false,"tokens":3780,"sizeBytes":16172},{"name":"CLAUDE.md","path":"e2e/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/e2e/CLAUDE.md","title":"e2e - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nGuidance for Claude Code sessions working in this directory. Covers both the operator-facing view (env vars, bootstrap, how to run) and the agent-facing context (architecture, gotchas, file layout).\n\n## What this directory is\n\nExternal Playwright suite targeting a deployed Infisical environment (gamma). Runs in CI between `gamma-deployment` and any prod-deploy job — defined as `gamma-playwright-e2e` in the `infrastructure` repo's `.github/workflows/infisical-deployment.yml`. A red run blocks all 11 prod/dedicated deploy jobs via `needs:`.\n\n**Not** the same as `backend/e2e-test/`, which is in-process Vitest with a bootstrapped server. That one tests the backend in isolation; this one tests the deployed product as a black box.\n\n## Scope discipline\n\nThis suite exists for **smoke-level** verification — \"does the surface still work end-to-end against real infrastructure.\" If a test is best expressed as \"given these inputs to a function, assert these outputs,\" it belongs in a unit or integration test, not here. The CI cost per test (minutes, not seconds) makes narrow coverage expensive.\n\nConcrete rule: a test here should be *useful even if every integration test passed*. It's catching things integration tests can't — config drift, deploy regressions, end-to-end auth flows that depend on real IdPs.\n\n## Architecture\n\n- **Playwright** — TypeScript, Chromium project only. Single worker, `fullyParallel: false`. The single-worker constraint is load-bearing — see \"Serial execution\" below.\n- **Two-token CI surface.** Only the two real secrets (`E2E_SCIM_TOKEN`, `E2E_IDP_ADMIN_TOKEN`) come from env vars — validated fail-fast in [`helpers/env.ts`](helpers/env.ts). Deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config row id) are constants in [`helpers/constants.ts`](helpers/constants.ts). The deliberate tradeoff: re-seeding gamma's e2e org rotates the `saml_configs.id`, which then has to be updated in code instead of in a GH Actions secret.\n- **Twingate-gated.** Gamma sits behind Twingate; the CI workflow installs the Twingate connector before tests run. Locally, you need to be on Twingate too. The Playwright Chromium inherits routes from the host network connector.\n- **Mock IdP we control.** SAML SSO points at `preview-orchestrator.infisical.workers.dev/saml-idp/*` — Hono routes on the existing `preview-environments` Worker, using `samlify` for signing under `nodejs_compat`. No SaaS account, no vendor DOM. The test calls `POST /saml-idp/identity` with a per-run NameID, sets the returned session id as a cookie on the Worker domain, then navigates to gamma's SAML start URL; the browser carries the cookie through gamma's 302 to the IdP, which mints a signed assertion for that identity. Cert is committed at [`fixtures/idp-cert.pem`](fixtures/idp-cert.pem); private key is in CF Secrets only (`SAML_IDP_PRIVATE_KEY`). Worker code lives in the `preview-environments` repo, not here — that side of the system is owned separately.\n- **Two SAML entry shapes, one spec file.** `tests/scim-saml-auth.spec.ts` covers both variants under one `describe`. SP-initiated navigates to gamma's `/api/v1/sso/redirect/saml2/organizations/:orgSlug` first; gamma 302s the browser to the Worker's `/saml-idp/sso` with an `AuthnRequest`. IdP-initiated starts at the Worker's `/saml-idp/initiate`, which mints an *unsolicited* signed `SAMLResponse` (no `InResponseTo` per SAML 2.0 §3.4.1.5) and auto-POSTs it directly to gamma's ACS at `/api/v1/sso/saml2/:samlConfigId`. The IdP-initiated test passes the gamma ACS URL through `POST /identity` (validated against the gamma SP origin allowlist), reachable via `gammaSamlAcsUrl()` in [`helpers/idp-client.ts`](helpers/idp-client.ts).\n\n## Load-bearing identity coupling\n\nThis is the one detail that, if you get it wrong, makes the test silently fail in confusing ways:\n\n```\nSCIM userName  ==  SAML NameID emitted by the IdP  ==  UserAlias.externalId\n```\n\nThe chain works because:\n- SCIM provisioning creates `UserAlias.externalId = req.body.userName` (backend `scim-router.ts:266` → `scim-service.ts:499`).\n- Org auth method is SAML, so the alias type is SAML (`scim-service.ts:428`).\n- SAML login looks up by `userAliasDAL.findOne({ externalId: profile.nameID, orgId, aliasType: SAML })` (`saml-config-service.ts:535`).\n\nWith the in-house mock IdP, the test now **owns this value end-to-end**: it generates a per-run `EXTERNAL_ID` (`e2e-${runId}@infisical-e2e.test`) and passes the same string as the SCIM `userName`, the IdP `email` (which samlify uses as the SAML `NameID`), and implicitly as `UserAlias.externalId`. There's no SaaS-side configuration to drift. If `tests/scim-saml-auth.spec.ts` and `helpers/idp-client.ts` agree on the value, the chain holds.\n\nThe current test still ends at `/signup/sso?token=…` rather than `/login/select-organization` — gamma routes any `userAlias.isEmailVerified = false` user through `SIGNUP_REQUIRED` (`auth-login-service.ts:322`), and SCIM hardcodes that field to `false` (`scim-service.ts:506`). The signup token JWT contains the verified identity fields. Patching the SCIM service to mark aliases verified at provisioning time would flip the redirect back to the original `/login/select-organization`; that's a separate backend change.\n\n## Serial execution is intentional\n\nTests run with a single worker. The old reason (single static IdP identity) is gone — the mock IdP can mint an assertion for any NameID the test asks for, so per-run unique externalIds already work.\n\nWhat still binds us to single-worker is the **shared gamma org**: there's one `saml_configs` row, one set of `email_domains` rows, one `orgAuthMethod`. Concurrent tests writing to the same org's `user_aliases`/`memberships` rows risk subtle interleavings (e.g. the same email landing on a fallback `userDAL.findOne({ username })` from two flows at once). Unblocking parallelism would mean either per-test gamma orgs (heavier seed script) or accepting that risk for tests that don't touch shared org state.\n\nIf you do enable parallel workers, also ensure the IdP `/saml-idp/identity` flow's cookie scoping survives — the Worker sets `path=/saml-idp`, so multiple concurrent tests can each set their own session cookie value on the Worker origin without collision (Playwright contexts are already isolated per test).\n\n## File layout\n\n| Path | Purpose | Touch when... |\n|---|---|---|\n| `playwright.config.ts` | Runner config — baseURL, retries, reporter | Adding a project (e.g. Firefox), changing retry policy |\n| `helpers/env.ts` | Fail-fast env loader for the two bearer-token secrets | Adding a new *secret* (also update `.env.example` and the CI workflow) — non-secret config goes in `constants.ts` instead |\n| `helpers/constants.ts` | Hardcoded deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config id) | Gamma's e2e org is re-bootstrapped and `saml_configs.id` rotates — update `SAML_CONFIG_ID`; the seed script logs the new value on the `[seed] E2E_SAML_CONFIG_ID = <uuid>` line |\n| `helpers/scim.ts` | SCIM CRUD wrappers, reusable across tests | Any new SCIM operation a test needs (e.g. Groups) |\n| `helpers/idp-client.ts` | Talks to the mock IdP — stashes a NameID (+ optional ACS URL for IdP-initiated), sets the Worker-domain session cookie, and starts either SP- or IdP-initiated flows | Adding a new SAML-flavored flow, or if the Worker IdP's API/cookie scheme changes |\n| `tests/*.spec.ts` | One test per flow | Adding a new flow (see below) |\n| `fixtures/idp-cert.pem` | Committed public cert for the mock IdP; gamma's SAML config row holds this same cert (KMS-encrypted) | Rotating the IdP keypair (see [`fixtures/README.md`](fixtures/README.md)) |\n| `tsconfig.json` | TS config — no `baseUrl`/`paths` on purpose, relative imports only | Avoid unless you have a real reason |\n\n## Adding a new test\n\n1. **One *flow* per file**, not one test per file. A flow is a distinct end-to-end path being verified (e.g. \"SCIM-provisioned user logs in via SAML\", \"SAML response rejection\", \"SCIM deactivation revokes SAML login\"). Variants of the *same* flow that differ only in entry shape or input (SP-initiated vs IdP-initiated SAML; each strictness gate within a rejection suite) belong in the same file under one `test.describe`, as multiple `test()` blocks. Distinct flows get their own file.\n2. Name the file after the flow (`scim-saml-auth.spec.ts`, `saml-rejection.spec.ts`, `scim-deactivation.spec.ts`). Per-variant naming goes inside the file as test titles.\n3. Import from `../helpers/constants`, `../helpers/env`, `../helpers/scim`, `../helpers/idp-client`. Add a new helper module only if a fundamentally new flow needs shared setup (e.g. an OIDC helper if/when that's covered).\n4. **Per-variant state goes in `beforeEach` / `afterEach`**, not `beforeAll` / `afterAll`. Each variant test should own its own SCIM user (fresh `externalId`) so a mid-test crash on one variant doesn't poison the next. See `saml-rejection.spec.ts` for the pattern. Use `beforeAll`/`afterAll` only when there's genuinely shared, idempotent state across the whole file.\n5. **Always clean up.** `beforeEach` defensively deletes any state the test creates; `afterEach` deletes what the test produced. Tests crash mid-flow sometimes; the e2e org accumulates rows otherwise. Note: the SAML signup branch also creates a fresh user row on each run — currently leaks; revisit when SCIM's `isEmailVerified` default is patched.\n6. **One assertion-block per test should be enough** to claim the variant works. Factor a shared `expect…()` helper inside the file when multiple tests assert the same outcome shape (e.g. `expectSignupSsoRedirectFor(page, email)` in `scim-saml-auth.spec.ts`) — that's preferable to copying ~10 lines of expect calls across tests. Don't expand a smoke test into a unit-test-style assertion sprawl — push fine-grained detail into integration tests.\n7. **Don't share helpers across spec files.** Helpers live in `helpers/*` or as file-local consts/functions at the top of the spec. Cross-spec imports create CI debugging cliffs — avoid.\n8. Run locally first (`npm test`) with `.env` populated. CI will replicate via GH Actions secrets.\n\n## What you can break by accident\n\nThese external dependencies have to stay in sync — if you change the backend or the Worker, the test breaks silently. Worth grepping for after any change in these areas:\n\n- **SCIM endpoint URL** (`backend/src/ee/routes/v1/scim-router.ts`, mounted at `/api/v1/scim`). `helpers/scim.ts` hardcodes `/api/v1/scim/Users` and `/api/v1/scim/Users/:id`. If the router prefix moves, update here.\n- **SAML start/ACS URLs** (`backend/src/ee/routes/v1/saml-router.ts`). `helpers/idp-client.ts` hardcodes `/api/v1/sso/redirect/saml2/organizations/:orgSlug` (SP-initiated) and `/api/v1/sso/saml2/:samlConfigId` (IdP-initiated ACS, built from `SAML_CONFIG_ID` in `helpers/constants.ts`). If either path moves, both consumer sites need to follow.\n- **Post-login redirect** (`saml-config-service.ts` → `auth-login-service.ts:322`). `tests/scim-saml-auth.spec.ts` asserts `/signup/sso?token=…`; if SCIM gets patched to mark aliases verified, the redirect flips to `/login/select-organization` and the assertion needs to follow.\n- **NameID-to-externalId mapping** (`saml-config-service.ts:535`). Currently `profile.nameID`. If the matching attribute changes, the identity-coupling section above becomes stale.\n- **`scimEnabled` / `orgAuthMethod` semantics** (`scim-service.ts:413, 419`). If new gating gets added (e.g. additional flags), the seed script in `backend/scripts/seed-gamma-fixtures.ts` needs to set them.\n- **Verified email domain check** (`email-domain-fns.ts:11`). SCIM POST and SAML login both require the email domain to be on the org's `email_domains` allowlist with `status='verified'`. The seed script writes one for `infisical-e2e.test`; if the test ever uses a different domain, add a row.\n- **Mock IdP cert ↔ gamma SAML config cert** must match. Seed script reads the committed cert from `e2e/fixtures/idp-cert.pem` and KMS-encrypts it into the `saml_configs` row; the Worker signs with the matching private key from `SAML_IDP_PRIVATE_KEY` (CF Secret). Rotating one requires rotating the other — see `fixtures/README.md`.\n- **passport-saml strictness** (`saml-router.ts:51-117`). Under `authProvider = keycloak-saml` (what the seed script writes), no overrides apply — passport-saml requires both Response and Assertion signed, attribute `email`/`firstName`/`lastName` present, and audience matching `appCfg.SITE_URL`. `validateInResponseTo` defaults to `\"never\"` in `@node-saml/node-saml` 5.x, which is why the IdP-initiated path works at all; flipping it to `\"ifPresent\"` or `\"always\"` would break IdP-initiated first. The Worker's unsolicited template omits `InResponseTo` entirely (per SAML 2.0 §3.4.1.5) so the test stays valid if that default is ever tightened. Changing the seed's `authProvider` value would change the strictness profile.\n\n## Common pitfalls\n\n- **Diagnostics about `@playwright/test`** before `npm install` runs — expected. Resolve by `cd e2e && npm install`.\n- **The seed script's bootstrap is one-pass now.** Direct DB writes don't trigger `updateSamlCfg`'s `scimEnabled=false` side effect, so a single run leaves a usable state. (The old SSOJet-era two-pass requirement is gone.) Re-run is still safe; it's idempotent.\n- **CF Access bypass for `/saml-idp/*`** must be in place on the `preview-orchestrator` Worker, otherwise every request to the mock IdP 302s to the CF Access login page. The bypass is safe because the IdP routes have their own auth (`Authorization: Bearer SAML_IDP_ADMIN_TOKEN` on `/identity`, cookie + KV lookup on `/sso` and `/initiate`, intentionally public on `/metadata.xml`).\n- **Worker changes ship via `wrangler deploy`**, not the gamma deploy. After editing Worker code (`/saml-idp/*` routes, IdP templates), deploy from the `preview-environments` repo before running the test or it'll hit the previous version. `tsc` passing here doesn't prove the Worker is up to date.\n- **SCIM POST + SAML signup leak a user row per test run** because the SAML signup branch creates a fresh `users` row even though SCIM already created one. SCIM `DELETE /Users/:id` removes the membership but not the orphan user. Tractable but accumulates; revisit if it becomes noisy.\n- **Don't add a `.npmrc` here for the 7-day minimum-release-age rule** — `e2e/` is a test harness, not a runtime artifact. The supply-chain concern that motivates the rule in `backend/`/`frontend/` doesn't apply.\n- **CI uses `npm ci`** against the committed `package-lock.json` for determinism. If you change deps, regenerate the lockfile (`npm install`) and commit it in the same change, otherwise `npm ci` fails the prod gate.\n- **Don't widen what the seed script creates** unless you've thought through the safety. It already refuses prod (host/dbname regex + `INFISICAL_ENV=prod` + interactive confirmation). Adding more powerful operations means re-evaluating the guard surface.\n\n## Where context lives\n\n- CI workflow: `infrastructure` repo, `.github/workflows/infisical-deployment.yml` (the `gamma-playwright-e2e` job)\n- Seed script: [`../backend/scripts/seed-gamma-fixtures.ts`](../backend/scripts/seed-gamma-fixtures.ts)\n- Backend SCIM service: `../backend/src/ee/services/scim/`\n- Backend SAML service: `../backend/src/ee/services/saml-config/`\n- Mock IdP Worker code: `preview-environments` repo, `src/saml-idp/` (separate repo — owned independently)\n- IdP keypair provenance + rotation: [`fixtures/README.md`](fixtures/README.md)\n- Root architectural notes: [`../CLAUDE.md`](../CLAUDE.md), [`../backend/CLAUDE.md`](../backend/CLAUDE.md)\n","isInternal":false,"tokens":3947,"sizeBytes":15726},{"name":"CLAUDE.md","path":"frontend/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/frontend/CLAUDE.md","title":"frontend - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **frontend** package of the Infisical monorepo — a React 19 SPA built with Vite 8, TanStack Router, React Query, and Tailwind CSS v4.\n\n## Essential Commands\n\nAll commands run from the `frontend/` directory:\n\n- `npm run dev` — start dev server on port 3000\n- `npm run build` — TypeScript check + Vite production build\n- `npm run lint:fix` — ESLint autofix (double quotes, simple-import-sort with 7 groups)\n- `npm run type:check` — TypeScript type check only (`tsconfig.app.json`)\n- `make reviewable-ui` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n- `npm run storybook` — Storybook on port 6006\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## Architecture Overview\n\n### Routing (TanStack Router v1)\n\nVirtual file routes defined in `src/routes.ts` using a functional DSL (`route()`, `index()`, `layout()`, `middleware()`). The generated route tree is in `src/routeTree.gen.ts` (do not edit manually — regenerated on dev server start or when `src/routes.ts` changes).\n\nEach page directory has a `route.tsx` that defines its route:\n```tsx\ncreateFileRoute(path)({ component, validateSearch: zodValidator(schema), beforeLoad })\n```\n\nRoute middleware chain: root → `authenticate` → `inject-org-details` → org-layout → product-specific layouts (secret-manager-layout, cert-manager-layout, kms-layout, etc.).\n\nMiddleware pages in `src/pages/middlewares/`: `authenticate.tsx` (auth guard + redirect), `inject-org-details.tsx` (org context), `restrict-login-signup.tsx` (prevents auth pages when logged in).\n\n### Pages / Views / Components Hierarchy\n\n- **`src/pages/`** — Route-level components organized by product feature (secret-manager, cert-manager, kms, pam, secret-scanning, organization, project, admin, auth). Each has `route.tsx` + page component + local `components/`.\n- **`src/views/`** — Reusable page-level UI composed into multiple pages. Pages import views with configuration props.\n- **`src/components/v3/`** — Latest shared UI component library (preferred). Contains `generic/` (Accordion, Alert, Button, Dialog, Select, Table, etc.) and `platform/` (domain-specific components such as `PageHeader`, scope icons, `ProjectPermissionSubjects`, and `SecretManagerResources`). **Always use v3 components for new code.** Only use v2 components when a v3 equivalent does not exist. Policy group tiles look up `getProjectPermissionSubjectPresentation`. Secret Manager overview resources (name/slug/icon/color) come from `SecretManagerResources`, which points at that subject catalog.\n- **`src/components/v2/`** — Legacy shared UI components built on Radix UI primitives + Tailwind. Uses `cva` (class-variance-authority) for variants and `tailwind-merge` for class conflict resolution. Being superseded by v3 — do not use for new features if a v3 alternative exists.\n\n### API Layer (React Query + Axios)\n\nEach API domain in `src/hooks/api/` (100+ domain folders) follows this structure:\n- **`queries.tsx`** — Query key factory pattern: keys return `[{ params }, \"domain-label\"]`. Export named query hooks (`useGetSecrets`, etc.) and raw fetch functions.\n- **`mutations.tsx`** — `useMutation` hooks that invalidate relevant query keys on success via `queryClient.invalidateQueries()`.\n- **`types.tsx`** — Request/response DTOs.\n\nHTTP client in `src/config/request.ts`: Axios instance with automatic token injection and 401/403 interceptors.\n\n#### React Query Global Defaults (`src/hooks/api/reactQuery.tsx`)\n\nThe `QueryClient` sets these global defaults for all queries:\n- **`staleTime: 60_000`** (60 seconds) — data fetched within the last 60s is considered fresh and won't be refetched on component mount/remount. This prevents redundant API calls during normal page navigation. Queries that need real-time data (e.g., identity auth configs, dynamic secret leases) override this with `staleTime: 0`.\n- **`refetchOnWindowFocus: false`** — queries do not refetch when the browser tab regains focus.\n- **`retry: 1`** — failed queries retry once.\n\nMutation failures are reported globally by `MutationCache.onError` using the server error message. Do not add another error `createNotification` in a mutation `catch` or `onError`, because it produces duplicate toasts. Local error handling may still restore optimistic state, keep a dialog open, or perform other control flow without displaying a second notification.\n\nWhen adding new queries, consider whether the default 60s staleTime is appropriate:\n- For data that changes only on explicit user action (secrets, folders, org metadata): the 60s default is fine or could be longer.\n- For data that must always be fresh (auth configs, lease TTLs): override with `staleTime: 0, gcTime: 0`.\n- For rarely-changing data (server config, user profile): use `staleTime: Infinity` as the context providers do.\n\nOrganization switching resets the query cache centrally in `pages/middlewares/inject-org-details.tsx`, after authentication succeeds and before tenant data loads. `hooks/api/organization/cache.ts` tracks the active organization per `QueryClient` and removes all queries except the exact auth-token, user-profile, server-config, and session-announcement keys. Removing queries also cancels pending query results, including queries whose keys omit the organization. Same-organization navigation keeps the cache, and the organization layout remounts on organization changes to reset local UI state. Keep tenant cache cleanup at this boundary rather than adding feature-specific removal lists to individual organization selectors. Logout paths must use `clearSession` to remove user-specific cache entries while preserving public server configuration for the login page.\n\nOrganization switchers use `helpers/organizationSwitch.ts` to retain safe sections and tabs while dropping resource IDs, action parameters, and legacy suborganization search state. Pass the current location through `navigateUserToOrg` only for organization switches; initial login keeps its Projects landing page. Explicit destinations take precedence, and MFA route handoffs retain the requested destination through `redirect_to`.\n\n### Product Analytics\n\nUse `@app/lib/analytics` for new frontend product analytics. Define the event and its typed properties in the shared catalog, then capture it through the method matching its scope so required grouping is applied automatically. Do not call the legacy `Telemetry` wrapper directly from new code or define event names as local strings.\n\nRead [`../ANALYTICS.md`](../ANALYTICS.md) before adding or changing an event. It defines naming, ownership between frontend and backend producers, property cardinality and privacy rules, and lifecycle semantics. Frontend events describe UI exposure and intent; decisive product outcomes belong to backend telemetry so web activity is not double-counted against CLI, machine identity, or other clients.\n\n#### PostHog feature flags and A/B tests\n\n- Put each experiment in `src/components/analytics/experiments/<experiment>/`, with its flag key, typed variants, assignment hook, and focused Vitest coverage kept together. Shared lifecycle code belongs in `src/lib/analytics/experiments/`.\n- Resolve PostHog flags through `resolveFeatureFlagVariant`. It performs a fresh lookup after flags load and owns timeout, error fallback, late-callback suppression, unsubscribe, and unmount cancellation. Set exposure-event behavior deliberately, and persist only values that the experiment resolver recognizes as real assignments.\n- Define the eligible population before initializing PostHog. Excluded flows must not request the flag or emit the experiment conversion. An eligible user who completes after assignment fails can be attributed as `unassigned`; an ineligible user must not be counted.\n- Keep an assignment sticky for the full conversion path, including redirects or region changes. Register dedicated conversion events and typed properties in `@app/lib/analytics`; do not reuse a broader event with different semantics or default unenrolled users into the control cohort.\n- Allow forced variants only in local development or pull-request previews. Production cloud domains must use PostHog assignment, and self-hosted instances must not initialize cloud experiments.\n- Create the PostHog flag and experiment as inactive drafts. Validate variant keys, exposure, conversion events, and metrics against a preview deployment before anyone launches the experiment separately from the code rollout.\n\n### State Management\n\n- **Server state**: TanStack React Query (query key factories in each API domain)\n- **Global app state**: React Context providers in `src/context/` — User, Organization, Project, OrgPermission, ProjectPermission, ServerConfig, Subscription\n- **Local component state**: Zustand stores\n\n### Permissions\n\nCASL-based (`@casl/ability`). Contexts: `OrgPermissionContext` and `ProjectPermissionContext` in `src/context/`. Access via `useOrgPermission()` / `useProjectPermission()` hooks. HOC gates: `src/hoc/withPermission/` and `withProjectPermission/`.\n\n### Styling\n\nTailwind CSS v4 with PostCSS. Dark theme configured via CSS custom properties in `src/index.css` (`@theme` directive). Custom breakpoint `dashboard: 1100px`. Typography roles: IBM Plex Sans is the default product UI face (`font-sans`); Alliance No. 2 Regular is for headings (`font-alliance`); JetBrains Mono is the functional mono (`font-mono`) for code, values, identifiers, and technical microcopy. See the root `DESIGN.md` for typography roles. Colors use semantic roles from `src/index.css`, including surfaces, content, structure, status, scope, and product/resource accents.\n\n### Layouts\n\n9 layout components in `src/layouts/` — `AdminLayout`, `OrganizationLayout`, `SecretManagerLayout`, `PkiManagerLayout`, `KmsLayout`, `PamLayout`, etc. Layouts handle sidebar navigation and page chrome for their product area.\n\n### Org-scoped products\n\nPAM and Agent Vault live at `/organizations/$orgId/<slug>` over a single implicit project, so their URLs carry no `$projectId`. Their layout's `beforeLoad` publishes `implicitProjectId` and `implicitProductType` into route context, and `useImplicitProjectId()` / `useImplicitProduct()` read them back off the matched routes. **Anything that decides whether a `useProject()` caller renders must use `useImplicitProduct()`, never the pathname.** The router changes location before it swaps matches, so a pathname gate mounts a frame early on the way in and unmounts a frame late on the way out, leaving the component asking for a project the router no longer resolves — which throws into `ErrorPage`. Reading the matches keeps these products in step with `useParams`, which is match-derived and is what every other product's gate already uses. The gates that matter today are `OrgSidebar`'s `isInsideProject`, the `ProjectSelect` wrapper, and `OrganizationCommandMenu` in `RootCommandMenu.tsx`, which had exactly this bug when it arrived from main with a pathname check; `getOrgScopedProductFromPath` remains correct for the cosmetic uses behind no `useProject` (navbar scope, `useScopeVariant`, `TypeSelect`).\n\nA new org-scoped product therefore needs: a `<product>ProjectId` on the `Organization` type, an entry in `ORG_SCOPED_PRODUCT_TYPES` and `getOrgScopedProductFromPath` (`helpers/project.ts`), and a layout `beforeLoad` that bootstraps the id, patches it into the cached org, and returns both `implicitProjectId` and `implicitProductType` (see `pages/agent-vault/layout.tsx`). Use `isOrgScopedProduct(type)` rather than adding another pathname check; the sidebar, navbar, project switcher and nav links all key off it.\n\nCertificate Manager is org-scoped too (`/organizations/$orgId/cert-manager/...`), but legacy orgs can hold several instances, so its layout resolves the project from instance state instead of an org field, through `resolveCertManagerProjectId` (`helpers/certManagerActiveProject.ts`): the `infisical-cm-active-project-<orgId>` cookie while the user is a member of that instance, else the active instance. The product switcher and the 403 page use the same helper. The layout writes the resolved id back to the cookie, because `inject-cert-manager-project-id.ts` resolves requests that name no project from it, and reloads the page when the resolved instance changes within one page load, since several cert-manager queries are not keyed by project. Consequences: anything that sends someone into a *specific* instance (the instance picker, a project-scoped identity row, a gateway's discovery row) sets that cookie first, since the URL can't say which; old `/projects/cert-manager/$projectId/...` URLs (emails and alerts sent before the move, bookmarks; the backend now links to `/cert-manager/...`, except legacy PKI Alerts V2 and the legacy-deprecation email) go through `redirects/cert-manager-project-url-redirect.tsx`, which pins the named instance and strips the id. In multi-instance orgs each tab also tags its Certificate Manager API requests with the instance it shows (`setTabCertManagerProjectId`, read by the `apiRequest` interceptor), because the cookie is shared across tabs; single-instance orgs send no project id. Shared views that pick project vs org scope from the URL use `useRouteProjectId()`, which also returns Certificate Manager's implicit project.\n\n### Single-project products\n\nSecret Scanning has one active project per org (see the root `CLAUDE.md`). Members are sent straight into it, and UI for orgs with older projects is gated on `isMultiInstance` from `useGetSecretScanningInstanceState`.\n\n## Conventions\n\n- ESLint flat config (ESLint 9+) with airbnb-typescript + prettier. Double quotes enforced.\n- Import ordering via `simple-import-sort`: node builtins → react/external packages → `@app/` → internal → relative → styles.\n- Forms use `react-hook-form` with `@hookform/resolvers` (Zod schemas).\n- Search params validated with `zodValidator()` from `@tanstack/zod-adapter`.\n- Toasts: call `createNotification({ title?, text, type, callToAction?, copyActions? })` from `@app/components/notifications`. Backed by **sonner** (the v3 `Toaster` in `components/v3/generic/Toast`), mounted once via `NotificationContainer` in `pages/root.tsx`. `react-toastify` has been removed, so do not reintroduce it.\n- Unsaved changes: use `useDiscardChangesGuard` with `DiscardChangesAlertDialog` when dismissing an overlay editor would remove unsaved form changes. Wire dismiss/close to `requestDiscard`. Do not use `window.confirm`. Do not use `useNavigationBlocker` — that hook is secrets batch-mode only.\n","isInternal":false,"tokens":3237,"sizeBytes":14694},{"name":"CLAUDE.md","path":"tests/CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/Infisical/infisical/HEAD/tests/CLAUDE.md","title":"tests - Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# CLAUDE.md\n\nThe Go blackbox suite. It boots a real Infisical in Docker and calls it over HTTP only.\nIts contract with the server is a Docker image, environment variables, and the OpenAPI\nspec, so the same tests run against any implementation of that contract.\n\n## Read next\n\n| Task | Read |\n|---|---|\n| Writing or reviewing a test | [docs/writing-tests.md](docs/writing-tests.md) |\n| Using or adding a fake third party, App Connection, or mail | [docs/fakes.md](docs/fakes.md) |\n| Calling an endpoint the client does not have yet | [docs/client.md](docs/client.md) |\n| A failing, flaky, or CI-only test | [docs/debugging.md](docs/debugging.md) |\n\n## What belongs here\n\nAnything a customer or API client can observe: responses, status codes, error messages,\nmail, calls to third parties, and effects of queues or crons after the response.\n\nNot here:\n\n- Pure functions and arithmetic. Unit test them in `backend/`.\n- Anything needing direct access to Infisical's own Postgres or Redis, an internal\n  service handle, or a mocked module. Those belong in `backend/e2e-test/`.\n- Exhaustive input validation. Cover representative cases here; matrices go in unit\n  tests.\n\n## Commands\n\n```bash\nmake test-suites   # product tests; starts and stops the stack\nmake test-harness  # harness tests\nmake test-all      # both\nmake test-unit     # no containers\nmake lint          # gofmt, vet, staticcheck\n```\n\nTo iterate on one package, keep the stack up:\n\n```bash\nmake up\ngo test ./suites/secretmanager/secrets/... -count=1\nmake down\n```\n\n`-count=1` disables Go's result cache. Run `make down` after changing a container's\nconfiguration, or the old container is reused.\n\n## Layout\n\n```\nsuites/             product behaviour, grouped by product (suites/secretmanager/secrets)\nharnesstest/        harness behaviour; only for failures the harness alone can cause\nharness/            Stack, Profile, Tenant, Principal\nfixture/            platform resources: projects, App Connections\nfixture/<product>/  one package per product: secretmanager, pki, pam\nfixture/sso/        SAML and OIDC logins, for any suite whose claim needs an SSO session\nfakes/              one package per external provider\ninternal/           wait, mail, id, apierr, spec\nclients/api/        generated client; never edit by hand\n```\n\n- Platform fixtures live in `fixture`; each product's fixtures live in\n  `fixture/<product>`. A product owns its nouns: a secret in Secret Manager is not a\n  secret in Cert Manager. Within a package, add a file per resource.\n- Helpers start in the test package and move to a fixture on the second caller.\n- Imports go one way: `fixture` may import `harness` and `fakes`; `harness` never imports\n  a fixture; `fakes` import neither.\n\n## Rules\n\n- `t.Parallel()` on every test and subtest under `Shared`; never under `Isolated`.\n- Every test body is `// Setup`, `// Action`, `// Assert`.\n- Assert with `testify/require` only.\n- Every test creates its own tenant.\n- Never build an API client by hand; use a principal's `.API` or `tn.Client`.\n- Never sleep. Wait on the status the product records, then assert the outcome.\n- Test plumbing once, not per provider.\n- A rejection test also checks that nothing changed.\n- Use the actor the claim is about, not `tn.Admin` by default.\n- No package, fixture, or option for a single caller.\n\n## Before you call it done\n\n```bash\nmake lint\nmake test-all\nmake up && go test -p 4 ./suites/... ./harnesstest/... -count=3; make down\n```\n\nThe last command is the flake gate. A failure there is shared state leaking across\ntenants. Then break the new assertion, and what it depends on, and confirm both fail.\n","isInternal":false,"tokens":902,"sizeBytes":3628}],"systemPromptSnippet":"<yakaai_skills repo=\"Infisical/infisical\">\n<!-- File: AGENTS.md (Tokens: ~1028 | Category: root-instruction) -->\n## Feature Development\n\nCheck out CLAUDE.md for additional context on project file structure and general feature development.\n\n## Backend Development\n\n**Read the [Backend Code Quality Guide](backend/CODE_QUALITY.md) for any change under `backend/`, and check your work against it before reporting the task done.** Features, refactors, bug fixes, and reviews all count.\n\nIt is a short, deliberately non-exhaustive floor: error messages users can understand (and no pointless 500s), validation on every API input, correct pagination when calling third-party APIs, no deadlock conditions on a small connection pool, REST-aligned API interfaces, and audit log events an admin can actually read.\n\nTreat that list as a summary of the guide's current contents, not as a condition for reading it. A change that does not look like any of those topics still gets checked, because the guide grows and because the items apply in places they are not obviously about (a bug fix that adds a query inside an existing transaction, a refactor that moves a third-party list call).\n\nSome of it needs judgment rather than a mechanical check. The deadlock rules cannot be caught by testing one request at a time. And a design that breaks REST should be raised with the author, with the conforming alternative proposed, rather than implemented silently or quietly \"fixed\" (some deviations are deliberate).\n\n## Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md), including the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/` (or `make lint-docs` for the whole site; the `Check docs style` CI check runs the branch variant). It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide. A clean run is not a substitute for reading the guide: the judgment calls it cannot check are the ones that matter most. Vale cannot see prose indented inside Mintlify components, so a clean run is not evidence that nested content was checked, and `Infisical.EmDashes` reports at warning level, so its findings never change the exit code.\n\n## UI Development\n\nWhen building frontend UI, follow [DESIGN.md](DESIGN.md) for the v3 design system — colors, typography, components, and voice.\n\nBefore changing a shared frontend component's lifecycle, read the [shared component lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) for current deprecations, blockers, and replacement decisions.\n\nPickers for entities a user may need to create first (app connections, gateways, and similar) must offer an inline \"Create new X\" row, gated on the create permission, so the user never has to leave the flow. Build them with the v3 `Combobox` and its `creation` prop, which renders that row; `Combobox` replaces both the deprecated `FilterableSelect` and `CreatableSelect`. For app connections, use `creation.mode = \"dialog\"` and have `renderForm` render `AppConnectionForm` with the app preset, selecting the new connection and calling `complete` in its `onComplete`, and route `onDismiss` and the form's cancel through `useDiscardChangesGuard` (see `CaInstallCertModal/useConnectionCreation.tsx`). A v2 `Modal` parent sits at `z-[60]`, above v3 overlays, so migrate it to the v3 `Dialog` when it hosts one (see `CaInstallCertModal/CaInstallCertModal.tsx`).\n\n## Issue and PR Guidelines\n\n- Never create a GitHub issue.\n- When creating a pull request, use and fully complete the repository's\n  `.github/pull_request_template.md` template.\n\n## Workflow Guardrails\n\n- Do not reformat existing code as incidental cleanup. Preserve the repository's current formatting unless a review agent, CI check, or user explicitly asks for a formatting change.\n- Never push changes to `license-fns` or local UI fixtures to a remote branch. Those edits are local-only support for review and test passes; keep them out of commits and pull requests.\n- When resolving merge conflicts, optimize for a correct, minimal, strategically unblocking resolution. Do not let a slow or stalled local check block the push when the corresponding frontend or backend CI checks on GitHub can validate the branch; push the resolved code and use CI to complete the expensive verification.\n\n<!-- File: DESIGN.md (Tokens: ~7709 | Category: project-spec) -->\n# Infisical Design System (v3)\n\nThis document captures the v3 visual language and product voice used across\nInfisical. It is the single reference for engineers, designers, and AI coding\nagents producing new UI or user-visible copy.\n\n**Source of truth for tokens and themes:** [`frontend/src/index.css`](frontend/src/index.css) (`@theme` blocks and theme overrides).\n**Canonical semantic reference:** [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx).\n**Canonical page references:** [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) and [`AccessControlPage`](frontend/src/pages/project/AccessControlPage).\n**Component usage reference:** Start with the sibling story in [`v3/generic`](frontend/src/components/v3/generic) or [`v3/platform`](frontend/src/components/v3/platform) when one exists, then check source and consumers. Some exports, including `CopyButton`, `DataGrid`, and `Sidebar`, have no sibling story; the [generic](frontend/src/components/v3/generic/index.ts) and [platform](frontend/src/components/v3/platform/index.ts) barrels are the complete export catalogs.\n\n---\n\n## 1. Visual Theme & Atmosphere\n\nInfisical is a security tool for operators. The interface reads like\ninfrastructure: dense, calm, and legible. Decorative treatments are reserved\nfor brand-forward surfaces such as authentication and onboarding; the core\nproduct remains utilitarian. Dark is the default medium; the page canvas is\n`--color-page`. Light mode is implemented through semantic token overrides and\nthe theme provider (with intentionally forced-dark authentication paths).\nCheck shared surfaces in both themes rather than hard-coding dark contrast.\n\nColor carries **meaning before brand**. A danger badge is red because the\naction is destructive, not because red is the accent. A project-colored button\nsignals project scope, not visual variety. Designers pick intent; hex values\nfollow.\n\nDepth is drawn primarily with borders and surface tones; the shared v3 `Card`\nadds a subtle `shadow-xs`. Motion is restrained and clarifies state changes;\ncontrol and collapse transitions often use 200ms, while overlays own their\nentry/exit timing. Respect reduced-motion behavior rather than assuming all\nanimations share one implementation. Secret values are masked by default;\nrevealing one is an intentional act.\n\n**Key characteristics:**\n\n- Dark default with light semantic overrides; `--color-page` page canvas\n- Semantic-first color (danger / success / warning / info / neutral)\n- Scope- and product-aware (org / sub-org / project / admin / PAM / Agent Vault)\n- Border-first depth with the shared `Card`'s `shadow-xs`; overlapping labels\n  retain the shared Badge styling\n- IBM Plex Sans for product UI, Alliance No. 2 for headings, and JetBrains\n  Mono for monospaced content\n- Secrets masked by default; reveal is an act\n\n## 2. Color Palette & Roles\n\nAll colors are defined as CSS custom properties in\n[`frontend/src/index.css`](frontend/src/index.css) and consumed via Tailwind v4\nutilities (`bg-org`, `text-danger`, etc.). Never introduce a hex that is not\nin this file.\n\n### Scope colors (hierarchy)\n\nUsed to signal the scope a surface, badge, or action belongs to.\n\n| Scope            | Token             |\n| ---------------- | ----------------- |\n| Organization     | `--color-org`     |\n| Sub-Organization | `--color-sub-org` |\n| Project          | `--color-project` |\n| Admin            | `--color-admin`   |\n\nPAM, Agent Vault and Secret Scanning are product identities rather than\nadditional levels in the org/project hierarchy. Use `--color-product-pam`,\n`--color-product-av` and `--color-product-ss` when the current product calls\nfor them; `PageHeader` and the org sidebar map those project types to these\naccents, while `Button` exposes `pam`, `av` and `ss` variants. Check each component's supported variants instead of treating all\nscope and product variants as interchangeable.\n\n### Semantic colors\n\n| Intent  | Token             | Use                                          |\n| ------- | ----------------- | -------------------------------------------- |\n| Success | `--color-success` | Healthy states, completed rotations          |\n| Info    | `--color-info`    | Informational states, external documentation |\n| Warning | `--color-warning` | Attention-warranting states, stale items     |\n| Danger  | `--color-danger`  | Destructive actions, errors, expired access  |\n| Neutral | `--color-neutral` | Disabled, muted, \"empty\" states              |\n\n### Surface & chrome\n\n| Role              | Token                     |\n| ----------------- | ------------------------- |\n| Page canvas       | `--color-page`            |\n| Background        | `--color-background`      |\n| Foreground text   | `--color-foreground`      |\n| Card surface      | `--color-card`            |\n| Popover / Sheet   | `--color-popover`         |\n| Container         | `--color-container`       |\n| Container (hover) | `--color-container-hover` |\n| Border            | `--color-border`          |\n| Focus ring        | `--color-ring`            |\n| Accent text       | `--color-accent`          |\n| Muted text        | `--color-muted`           |\n| Label text        | `--color-label`           |\n\nThe `mineshaft-*` scale (50–900) is the underlying neutral ramp; see\n`index.css` for the full list. Prefer semantic tokens (`card`, `border`,\n`accent`) over raw mineshaft values.\n\n### Product-area accents (secret-manager)\n\nReserved for resource types in the secret management product:\n`--color-folder`, `--color-secret`, `--color-dynamic-secret`,\n`--color-import`, `--color-secret-rotation`, `--color-override`,\n`--color-proxied-service`.\nDo not repurpose these for generic UI.\n\n### Variant treatments\n\nChoose the component variant by semantic intent or actual scope/product, not\nby a preferred hue. Named semantic and scope tints commonly use these recipes:\n\n- **Badge** — `bg-<c>/15 border-<c>/10 text-<c>`, hover `bg-<c>/35`\n  (see [`Badge.tsx`](frontend/src/components/v3/generic/Badge/Badge.tsx))\n- **Button** — `bg-<c>/10 border-<c>/25 text-foreground`, hover `bg-<c>/15 border-<c>/30`\n  (see [`Button.tsx`](frontend/src/components/v3/generic/Button/Button.tsx))\n\n`Button` product variants (`pam`, `av`, `product`) use `/25` backgrounds and\n`/30` borders. Neutral/default, outline, ghost, shaded, and link treatments\nhave their own contracts; the tint recipes are not universal. Read the source\nfor exact classes and supported variants.\n\n## 3. Typography\n\nTypography uses three intentional families:\n\n- **IBM Plex Sans** (`font-sans`, `--font-sans`) is the default product UI face.\n  Body copy, forms, tables, buttons, labels, and navigation inherit it. Real\n  400, 500, 600, and 700 weights are bundled for UI hierarchy.\n- **Alliance No. 2** (`font-alliance`, `--font-alliance`) is the heading face.\n  Semantic headings and shared page, card, dialog, alert dialog, and sheet\n  titles use its bundled Regular weight. Do not use it for arbitrary bold body\n  text or request synthetic heavier weights.\n  Product and project tile names are titles, not descriptions. Use `CardTitle`\n  for them. Do not override semantic headings or shared titles with medium,\n  semibold, or bold weights; express hierarchy with size and spacing instead.\n- **JetBrains Mono** (`font-mono`, `--font-mono`) is the functional and\n  decorative mono. Use it for code, secret values, identifiers, timestamps,\n  logs, and short technical microcopy.\n\n| Role                    | Class                                                                     | Notes                                                              |\n| ----------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------ |\n| Page title (h1)         | `font-alliance text-2xl font-normal underline underline-offset-4 decoration-<scope>/90` | In `PageHeader`; scope icon (size 26) sits inline before the title |\n| Page description        | `text-label`                                                              | Sits under the title, separated by the header's `gap-2`            |\n| Card title              | `font-alliance text-lg font-normal leading-none`                           | `flex gap-1.5` so badges can sit inline                            |\n| Card description        | `text-sm text-accent`                                                     |                                                                    |\n| Body                    | `text-sm`                                                                 | Default for table cells, form values, dialog content               |\n| Label / meta            | `text-xs text-accent`                                                     | Field labels, table column captions, metadata                      |\n| Field helper / feedback | `text-2xs leading-snug`                                                   | Defaults via `FieldDescription` and `FieldFeedback`                |\n| Badge                   | `text-xs` (auto, via `Badge`)                                             | Never override                                                     |\n| Button                  | `text-sm` (md/sm/lg), `text-xs` (xs)                                      | Auto via `Button` sizing                                           |\n\nOn brand-forward surfaces, a short phrase may use the `text-highlight`\nutility to create the solid project-yellow text block. Keep the phrase short,\nuse it as typographic emphasis rather than status or scope, and do not apply it\ninside routine product UI.\n\nSentence case for descriptions, helper text, and empty states. Title Case for\npage titles, card titles, dialog titles, sheet titles, button labels, badge\nlabels, and dropdown menu items. See §8 for voice rules on copy itself.\n\n## 4. Component Stylings\n\nPrefer v3 components from [`frontend/src/components/v3/`](frontend/src/components/v3)\nfor new UI. The v2 library is legacy; use it for new work only when v3 does not\ncover the required behavior. Existing v2 consumers marked blocked in the\n[lifecycle ledger](frontend/src/components/COMPONENT_LIFECYCLE.md) need\nconsumer-specific parity checks before replacement, even on mixed pages.\n\nFor exact tokens, classes, and supported variants, read component source and\nthe story when present; stories may not enumerate every implemented variant.\n\n### Reading the stories\n\nWhere present, stories may use these conventions:\n\n- **`Variant: X`** stories — one per prop-driven variant (e.g. `Variant: Outline`).\n- **`Example: X`** stories — composition recipes (e.g. `Example: With Header`,\n  `Example: Inside Card / Sheet / Dialog`).\n- Story descriptions or component-level metadata may explain when to use a composition.\n\nUse the closest example as a starting point, not a mandatory composition.\nSome stories use other names or omit descriptions; check component props,\nexports, and representative consumers when guidance is absent.\n\nRun Storybook with `cd frontend && npm run storybook` (port 6006) to preview.\n\n### Component inventory\n\nUse these tables to find the component for a given intent. For props,\nvariants, sizes, and class lists, open the source and any available story.\nThe [generic](frontend/src/components/v3/generic/index.ts) and\n[platform](frontend/src/components/v3/platform/index.ts) barrels also expose\nspecialized primitives not listed here (for example `CodeBlock`, `CopyButton`,\n`RadioGroup`, `TagsInput`, `FileDropzone`, `DurationInput`, `ColorPicker`).\n\n#### Actions\n\n| Component                                                                       | Reach for this when…                                                                                                                                                                                                                          |\n| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| [`Button`](frontend/src/components/v3/generic/Button/Button.tsx)                | A text-bearing button — primary or secondary action.                                                                                                                                                                                          |\n| [`ButtonBadge`](frontend/src/components/v3/generic/Button/ButtonBadge.tsx)      | A short decorative flag attached to a Button corner—such as \"Last Used\", \"New\", or \"Recommended\". It inherits Badge typography, sizing, radius, border, and casing; only its corner positioning and solidified semantic tint are specialized. |\n| [`IconButton`](frontend/src/components/v3/generic/IconButton/IconButton.tsx)    | A square icon-only button — toolbars, row actions, compact triggers. Always `aria-label`.                                                                                                                                                     |\n| [`ButtonGroup`](frontend/src/components/v3/generic/ButtonGroup/ButtonGroup.tsx) | Visually join related controls — toolbars, segmented controls, split buttons, key-value chips.                                                                                                                                                |\n| [`Dropdown`](frontend/src/components/v3/generic/Dropdown/Dropdown.tsx)          | An action menu — overflow `⋯`, split-button alternates, contextual lists.                                                                                                                                                                     |\n\n#### Forms\n\n| Component                                                                                                                                 | Reach for this when…                                                                                        |\n| ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |\n| [`Field`](frontend/src/components/v3/generic/Field/Field.tsx)                                                                             | Compose labeled or validated form controls with the slots they need. `FieldContent`, description, and error are optional; a standalone filter/search control need not be a form field. |\n| [`Label`](frontend/src/components/v3/generic/Label/Label.tsx)                                                                             | Standalone form label outside a `Field`.                                                                    |\n| [`Input`](frontend/src/components/v3/generic/Input/Input.tsx) / [`TextArea`](frontend/src/components/v3/generic/TextArea/TextArea.tsx)    | Single-line / multi-line text entry.                                                                        |\n| [`InputGroup`](frontend/src/components/v3/generic/InputGroup/InputGroup.tsx)                                                              | Input with left/right addons — search bars, prefixed values.                                                |\n| [`Select`](frontend/src/components/v3/generic/Select/Select.tsx)                                                                          | Non-searchable single-select with a short, known option list.                                               |\n| [`Combobox`](frontend/src/components/v3/generic/Combobox/Combobox.tsx)                                                                    | Searchable single- or multi-select, including grouped/rich rows and inline or dialog creation when its contract fits. |\n| [`ReactSelect`](frontend/src/components/v3/generic/ReactSelect/index.ts)                                                                  | Deprecated compatibility for consumers needing react-select-specific overrides; see the lifecycle ledger. |\n| [`Toggle`](frontend/src/components/v3/generic/Toggle/Toggle.tsx) / [`Checkbox`](frontend/src/components/v3/generic/Checkbox/Checkbox.tsx) | Boolean toggle / multi-select boolean.                                                                      |\n| [`Calendar`](frontend/src/components/v3/generic/Calendar/Calendar.tsx)                                                                    | Date / multi-date / range picker primitive.                                                                 |\n| [`DateRangeFilter`](frontend/src/components/v3/platform/DateRangeFilter/DateRangeFilter.tsx)                                               | Platform date-range filter with presets — for filter bars.                                                  |\n| [`SecretInput`](frontend/src/components/v3/platform/SecretInput/SecretInput.tsx)                                                           | Platform secret-value editor with mask toggle and `${var}` highlighting.                                    |\n| [`PasswordGenerator`](frontend/src/components/v3/platform/PasswordGenerator/PasswordGenerator.tsx)                                         | Platform password generator against project secret-validation rules.                                        |\n| [`EmailInput`](frontend/src/components/v3/platform/EmailInput/EmailInput.tsx)                                                              | Platform chip field for one or more free-text email addresses. Validate it with `emailListSchema` from `@app/helpers/email`; check single addresses or picker-created invitees with `isValidEmail` rather than a local regex or `z.string().email()`. |\n\n#### Containers & overlays\n\n| Component                                                                       | Reach for this when…                                                                                                         |\n| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |\n| [`Card`](frontend/src/components/v3/generic/Card/Card.tsx)                      | Default section container — tables, filters, forms, empty states all live in a Card.                                         |\n| [`Sheet`](frontend/src/components/v3/generic/Sheet/Sheet.tsx)                   | Panel for larger forms or workspaces; right is the usual side. Choose `size=\"form\"`, `\"wide\"`, or `\"workspace\"` by content, using the [size examples](frontend/src/components/v3/generic/Sheet/Sheet.stories.tsx); other sides serve specific flows. |\n| [`Dialog`](frontend/src/components/v3/generic/Dialog/Dialog.tsx)                | Centered modal — **use for small create/edit forms** (1–2 fields, single confirmation prompt) and short interactive prompts. |\n| [`AlertDialog`](frontend/src/components/v3/generic/AlertDialog/AlertDialog.tsx) | Confirm an action (destructive included). Replaces `confirm()`.                                                              |\n| [`Popover`](frontend/src/components/v3/generic/Popover/Popover.tsx)             | Anchored floating panel — filters, pickers, contextual UI.                                                                   |\n| [`Tooltip`](frontend/src/components/v3/generic/Tooltip/Tooltip.tsx)             | Small floating annotation on hover/focus.                                                                                    |\n| [`Accordion`](frontend/src/components/v3/generic/Accordion/Accordion.tsx)       | Collapsible sections.                                                                                                        |\n\n#### Data display\n\n| Component                                                                    | Reach for this when…                                                                                                  |\n| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |\n| [`Table`](frontend/src/components/v3/generic/Table/Table.tsx)                | Read-mostly list of records with sortable columns. Pair with `Empty` + `Pagination`.                                  |\n| [`DataGrid`](frontend/src/components/v3/generic/DataGrid/data-grid.tsx)      | Editable spreadsheet-style grid — copy/paste, multi-cell selection, keyboard nav. Use only when `Table` isn't enough. |\n| [`Pagination`](frontend/src/components/v3/generic/Pagination/Pagination.tsx) | Page controls under a Table or list.                                                                                  |\n| [`Item`](frontend/src/components/v3/generic/Item/Item.tsx)                   | Vertically-stacked list rows with shared spacing — when a `Table` is too heavy.                                       |\n| [`Detail`](frontend/src/components/v3/generic/Detail/Detail.tsx)             | Read-only label/value pairs in a detail view.                                                                         |\n| [`Badge`](frontend/src/components/v3/generic/Badge/Badge.tsx)                | Small label or chip — status, scope tag, key/value pair.                                                              |\n| [`SelectedActionBar`](frontend/src/components/v3/generic/SelectedActionBar/SelectedActionBar.tsx) | Floating batch actions for a selected set of records. |\n\n#### Navigation & search\n\n| Component                                                                    | Reach for this when…                              |\n| ---------------------------------------------------------------------------- | ------------------------------------------------- |\n| [`Sidebar`](frontend/src/components/v3/generic/Sidebar/Sidebar.tsx)          | Scope-aware product navigation panel.             |\n| [`Breadcrumb`](frontend/src/components/v3/generic/Breadcrumb/Breadcrumb.tsx) | Hierarchical location trail at the top of a page. |\n| [`Command`](frontend/src/components/v3/generic/Command/Command.tsx)          | Search-driven command palette / typeahead list.   |\n| [`Tabs`](frontend/src/components/v3/generic/Tabs/Tabs.tsx)                   | Switch between related views in the same context. |\n| [`Stepper`](frontend/src/components/v3/generic/Stepper/Stepper.tsx)          | Show progress through a staged workflow.          |\n\n#### Feedback & loading\n\n| Component                                                                     | Reach for this when…                                             |\n| ----------------------------------------------------------------------------- | ---------------------------------------------------------------- |\n| [`Alert`](frontend/src/components/v3/generic/Alert/Alert.tsx)                 | Inline message banner inside a page or Card.                     |\n| [`Toast`](frontend/src/components/v3/generic/Toast/Toast.tsx)                 | Transient post-action feedback. Replaces `alert()`.              |\n| [`Empty`](frontend/src/components/v3/generic/Empty/Empty.tsx)                 | Zero-state placeholder — pair with Table, list, or empty filter. |\n| [`Skeleton`](frontend/src/components/v3/generic/Skeleton/Skeleton.tsx)        | Shimmer placeholder while data is loading.                       |\n| [`Spinner`](frontend/src/components/v3/generic/Spinner/Spinner.tsx)           | Project-accented spinner by default; choose `xs`/`sm` for compact waits. |\n| [`Loader`](frontend/src/components/v3/generic/Loader/Loader.tsx)              | Branded loading animation — pending controls and page waits.     |\n| [`PageLoader`](frontend/src/components/v3/platform/PageLoader/PageLoader.tsx) | Centered Lottie spinner for full-page loading.                   |\n\n**Reduced motion.** When the operating system requests reduced motion, `Loader`\nand `PageLoader` hold the animation on its first frame instead of looping. The\nmark stays fully visible either way, because these Lotties animate only a\ntrim-path offset over an always-drawn mark. `role=\"status\"` is present in both\nstates, so the wait is still announced.\n\n#### Atoms & domain\n\n| Component                                                                                                         | Reach for this when…                                                                  |\n| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |\n| [`Separator`](frontend/src/components/v3/generic/Separator/Separator.tsx)                                         | Horizontal/vertical divider.                                                          |\n| [`AccessRestrictedNotice`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedNotice.tsx) | Inline restriction for a section. |\n| [`AccessRestrictedDialog`](frontend/src/components/v3/platform/AccessRestricted/AccessRestrictedDialog.tsx) | Page/tab access gate rendered in document flow, despite its name. |\n| [`ScopeIcons`](frontend/src/components/v3/platform/ScopeIcons.tsx)                                                | `OrgIcon` / `SubOrgIcon` / `ProjectIcon` / `InstanceIcon` — use when intent is scope. |\n| [`SecretManagerResources`](frontend/src/components/v3/platform/SecretManagerResources/SecretManagerResources.ts) | Canonical Secret Manager resource catalog (icon, color classes, name, slug, permission subject). |\n| [`ProjectPermissionSubjects`](frontend/src/components/v3/platform/ProjectPermissionSubjects/ProjectPermissionSubjects.ts) | Icon and tile color for every project policy subject (`ProjectPermissionSub`). |\n| [`PageHeader`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx)                                     | Canonical full-width page heading with scope semantics, description, and page actions. |\n| [`DocumentationLinkBadge`](frontend/src/components/v3/platform/DocumentationLinkBadge/DocumentationLinkBadge.tsx) | Inline \"Documentation\" link badge in `CardTitle`.                                      |\n\n**Icons** — [`lucide-react`](https://lucide.dev). Sizing is bound by the\nhost component; don't override unless necessary.\n\n## 5. Layout Principles\n\n- **Page container** — `max-w-8xl` (88rem) centered, `bg-page`.\n- **Page header** — v3 `PageHeader` with scope/product icon + underlined `h1` + description. Import it from `@app/components/v3` and set `scope` to `org`, `namespace`, `instance`, or the applicable `ProjectType`. See [`PageHeader.tsx`](frontend/src/components/v3/platform/PageHeader/PageHeader.tsx).\n- **Header boundary** — `PageHeader` owns the normal 40px bottom margin (`mb-10`). Do not repeat that margin at ordinary callers or add a parent row gap between the header and body. Keep independent card/section spacing in the body layout. Use `className` for genuine compact/responsive cases, such as `mb-6 md:mb-10` for Overview; see the header's spacing stories.\n- **Section** — one `Card` per logical section. Title + optional `DocumentationLinkBadge` in `CardHeader`; primary action in `CardAction` (top-right).\n- **Tables inside Cards** — filters and search sit in the `CardHeader` above the table; pagination sits in the `CardFooter` or bottom of `CardContent`. **Empty state** — when the table has no rows (and isn't loading), hide the `Table` entirely and render `Empty` in its place; never leave a column header floating above a blank body. Add `className=\"border\"` to `Empty` whenever it's nested in a `Card`, `Sheet`, or `Dialog` so the dashed frame is visible against the parent surface (the component ships dashed-but-borderless on purpose for page-level use).\n- **Forms inside Sheets/Dialog** — create / edit flows open in a Sheet or Dialog, never inline, never as a full-page route. **Pick by form size:** small forms (1–2 fields, e.g. \"Add domain\", \"Rename\") go in a centered `Dialog`; large or multi-step forms (multiple fields, scrollable detail, file uploads, wizard steps) go in a right-side `Sheet`. When in doubt, default to Dialog — Sheet is for cases where Dialog feels cramped.\n- **Authentication form stacks** — use `CardContent className=\"flex flex-col gap-4\"` for the major vertical rhythm between fields, validation groups, and primary actions. Use a nested `flex flex-col gap-2` only for compact sets of equivalent actions such as social or SSO provider buttons. Use `FieldSeparator` between alternate authentication paths. Do not recreate sibling spacing with `mt-*`, `mb-*`, or `py-*`; keep a wrapper only when it owns animation, responsive visibility, conditional grouping, or tightly coupled control/error content.\n- **Spacing rhythm** — `gap-1.5` (intra-element), `gap-2 / gap-3` (adjacent elements), `p-4 / p-5` (section padding). Card = `p-5 gap-5`; Sheet header/footer = `p-4`.\n- **Sheet and Dialog footers** — actions sit right-aligned with the primary action last: `SheetFooter className=\"justify-end border-t\"` (`DialogFooter` is right-aligned by default), then Cancel (`ghost`), then the primary button. Back navigation in a multi-step flow sits on the left with `mr-auto`, and wizard footers with status text on the left use `justify-between`. A single full-width action needs no alignment class.\n\n## 6. Depth & Elevation\n\nDepth is conveyed primarily by layered surface tones and borders. The shared\nv3 `Card` includes `shadow-xs` by default; stronger shadows are reserved for\nelements that float (Popover, DropdownMenu, Sheet, SelectedActionBar).\n\n| Layer           | Surface                                  | Border                              |\n| --------------- | ---------------------------------------- | ----------------------------------- |\n| Page            | `bg-background`                          | —                                   |\n| Card            | `bg-card`                                | `border-border`                     |\n| Popover / Dropdown main menu | `bg-popover`               | `border-border` + `shadow-md`       |\n| Dropdown submenu | `bg-popover`                           | `border-border` + `shadow-lg`       |\n| Sheet           | `bg-popover`                             | `border-border` + `shadow-lg`       |\n| Floating bar    | `bg-popover`                             | `border-border` + `shadow-floating` |\n| Row hover       | `bg-container-hover`                     | —                                   |\n| Focus           | —                                        | Component-owned focus-visible treatment (Button/IconButton use `ring-2`; default Input uses `ring-[3px]`) |\n| Disabled        | `opacity-50 / 75`, `pointer-events-none` | —                                   |\n\nDo not add a shadow to a Card beyond its shared `shadow-xs` default, or to a\nTable row, standalone Badge, or `ButtonBadge`. An overlapping `ButtonBadge`\nuses its solidified semantic tint—not custom content styling or elevation—to\nremain legible across the control edge.\n\n## 7. Do's and Don'ts\n\n- **DO** choose Badge and Button variants by **intent** (danger / success /\n  warning / info / neutral), not by color preference.\n- **DO** use scope colors (`org`, `sub-org`, `project`, `admin`) to reinforce\n  hierarchy, and product colors (`pam`, `av`) for their own product surfaces.\n- **DO** mask secret values by default. Reveal must be an explicit user\n  action and should be logged.\n- **DO** put large create / edit forms in a right-side Sheet; smaller forms can be in Dialogs.\n- **DO** pair destructive confirmations with the resource name and the\n  consequence (see §9).\n- **DO** cite tokens (`bg-card`) over hex (`#xxxxxx`) in new code.\n- **DON'T** replace existing blocked v2 consumers based on matching component names alone; check the lifecycle ledger and interaction parity first.\n- **DON'T** add ad hoc box-shadows as a depth cue — borders and surface tones do\n  that work. The shared `Card` already has `shadow-xs`; floating elements\n  (Popover, DropdownMenu, Sheet, SelectedActionBar) include stronger shadows.\n- **DON'T** invent new colors. If it isn't in `index.css` `@theme`, it\n  doesn't belong.\n- **DON'T** use `project` yellow, `org` blue, or `sub-org` green as generic\n  accents. They are scope signals; repurposing them creates false hierarchy.\n  The documented brand-forward `text-highlight` treatment and deliberately\n  branded `ButtonBadge` flags are narrow exceptions for authentication and\n  onboarding surfaces.\n- **DON'T** mix font families arbitrarily. Use IBM Plex Sans for UI and body\n  text, Alliance Regular for headings, and JetBrains Mono (`font-mono`) for\n  monospaced content.\n- **DON'T** animate for decoration. Motion should clarify state change only.\n\n## 8. Voice & Content Tone\n\nCopy should read as if written by an engineer for another engineer: direct,\ntechnical, specific. The domain is serious — secrets, access, compliance —\nand the voice reflects that.\n\n### Stance\n\n- Direct. Active voice. Lead with the subject: \"Delete this role\" — not\n  \"This role will be deleted\".\n- Specific. Name the resource, the action, the consequence. Avoid vague\n  verbs (\"handle\", \"manage\") when a precise verb exists (`rotate`, `revoke`,\n  `import`).\n- Calm. No exclamation marks. No second-person cheer (\"Awesome!\",\n  \"You're all set!\"). No emoji.\n- Honest. Never claim speed, power, or ease in UI copy (\"seamless\",\n  \"powerful\", \"blazing-fast\"). Those belong on the marketing site, not here.\n\n### Shapes\n\n- **Labels & buttons** — Title Case, imperative: \"Add Secret\", \"Revoke\n  Access\", \"Rotate Key\".\n- **Title Case applies to** — buttons, badges, dropdown menu items, card\n  titles, dialog titles, sheet titles, page titles, tab labels, table column\n  headers. Anything that names a thing or an action is Title Case.\n- **Descriptions & helper text** — sentence case, one short sentence.\n- **Empty states** — state what's missing, then the next action:\n  \"No secrets yet. Add your first secret to get started.\"\n- **Errors** — name the failure and the remedy. Never \"Something went wrong\":\n  \"Could not rotate secret — token lacks `secrets:write` permission.\"\n- **Destructive confirmation** — name the resource and the consequence \"Delete \"API_KEY\" — this cannot be undone.\"\n- **Success toasts** — past tense, specific: \"Secret \"API_KEY\" created\".\n\n### Secrets & sensitive values\n\nNever include a secret's value in any user-visible copy — UI, logs, toasts,\nerrors, audit trails, or analytics. Refer to secrets by key only. Mask\ntokens and keys in screenshots and docs as well.\n\n### Documentation links\n\nUse `DocumentationLinkBadge` (info variant, external-link icon). Label it\n\"Documentation\" — not \"Learn more\", \"Read docs\", \"See more\".\n\n## 9. Agent Prompt Guide\n\nPasteable prompt fragments for AI coding agents producing new UI.\n\n**Before generating UI for any component:**\n\n1. Find the export in the v3 generic or platform barrel and read its source.\n2. If a sibling story exists, consult its examples and use-when guidance.\n3. Pick a supported variant by intent and current scope/product, not color preference.\n\n**Adding a section to an existing page:**\n\n> Wrap the section in a `Card` from `@app/components/v3`. Use `CardHeader`\n> with `CardTitle` + optional `CardDescription` + `CardAction` for the\n> top-right primary button (the variant for the current scope or product,\n> when supported). Put the table or content in `CardContent`.\n\n**A new create/edit form:**\n\n> Pick the container by form size. **Small forms (1–2 fields, e.g. \"Add\n> domain\", \"Rename\"):** centered `Dialog` (`Dialog`, `DialogContent`,\n> `DialogHeader` with `DialogTitle` + `DialogDescription`, `DialogFooter`\n> with the action buttons). **Large or multi-step forms (many fields,\n> scrollable detail, wizards):** right-side `Sheet` (`Sheet`, `SheetContent`,\n> `SheetHeader` with `SheetTitle` + `SheetDescription`, `SheetFooter` with\n> the action buttons). Use `react-hook-form` with a Zod resolver in both\n> cases. Compose labeled/validated inputs in `Field` with `FieldLabel` and\n> only the description, content layout, or feedback slots needed. Primary\n> button variant follows the current scope/product (`project`, `org`,\n> `sub-org`, `pam`, or `av`, when supported); cancel is `ghost`.\n\n**A status indicator:**\n\n> Use `Badge` from `@app/components/v3`. Pick the variant by intent:\n> `danger` for errors or expired access, `warning` for stale or\n> attention-warranting, `success` for healthy / completed, `info` for\n> informational, `neutral` for disabled / empty, `project` / `org` /\n> `sub-org` for scope references. Include a matching Lucide icon as the\n> first child.\n\n**A destructive confirmation:**\n\n> Use `AlertDialog`. Title: \"Delete `<resource-name>`\". Description: one\n> sentence naming the consequence, ending with \"This cannot be undone.\"\n> Confirm button is variant `danger`. Cancel button is variant `outline`.\n\n**Unsaved changes:**\n\n> Use `DiscardChangesAlertDialog` with `useDiscardChangesGuard`. Title:\n> \"Discard Changes?\". Description names what will be lost. Confirm is\n> Discard (`danger`); cancel is Keep Editing. Overlay editors intercept\n> close with `requestDiscard`. Do not use `window.confirm`.\n\n**A documentation link in a section:**\n\n> Use `DocumentationLinkBadge` from `@app/components/v3/platform`. Place it\n> in the `CardTitle` next to the section name.\n\n**Refer to:**\n\n- [`Badge.stories.tsx`](frontend/src/components/v3/generic/Badge/Badge.stories.tsx) — canonical semantic reference for variant choice.\n- [`OverviewPage`](frontend/src/pages/secret-manager/OverviewPage) — full-page composition reference (v3 PageHeader, Card-with-table, Create Secret Sheet, filters, DropdownMenu + ButtonGroup).\n- [`AccessControlPage`](frontend/src/pages/project/AccessControlPage) — full-page reference (permission-gated actions, `DocumentationLinkBadge`, role badges with `ClockAlertIcon` for expired access).\n- §8 above for any user-visible copy.\n\n## Appendix: Iteration Guide\n\n1. **Run Storybook** — `cd frontend && npm run storybook` (port 6006). Open\n   Badge, Button, Card, Table, Sheet first.\n2. **Read the two reference pages** — `OverviewPage` and `AccessControlPage`\n   render the full v3 vocabulary in production.\n3. **Tokens live in `index.css`** — `@theme` blocks and theme overrides. Never\n   introduce a hex that is not here.\n4. **Adding a variant** — extend the `cva()` block in the component and add\n   a story when appropriate. Match the existing variant family rather than\n   imposing a tint recipe on neutral, outline, or product variants.\n5. **Prefer v3 for new code** — use v2 only where needed for behavior not covered\n   by v3. Existing blocked v2 consumers migrate after lifecycle parity review.\n6. **Before merging** — `make reviewable-ui` (lint + type-check).\n7. **When in doubt** — mirror `OverviewPage`.\n\n<!-- File: .agents/skills/docs-style/SKILL.md (Tokens: ~1319 | Category: anthropic-skill) -->\n---\nname: docs-style\ndescription: Write, edit, and review Infisical documentation to the house style guide. Use whenever creating or changing any file under docs/ (.mdx pages, snippets, docs.json navigation), when asked to document a feature, write or update a guide, or review a docs diff or pull request. Covers running the Vale linter and the sentence-level review that Vale can't do.\n---\n\n# Infisical docs style\n\n`docs/STYLE_GUIDE.md` holds every rule for Infisical documentation. This skill doesn't repeat\nthe rules. This skill describes how to apply them.\n\n**Before you write or review anything, read all of `docs/STYLE_GUIDE.md` with the Read tool.**\nDon't work from a summary, a diff, or your memory of the guide. Each rule in the guide comes\nwith an \"Instead of\" and \"Write\" example, and the examples show what the rule means better\nthan any summary can.\n\n## Templates\n\nTo check if the page you're documenting has a template to follow, start from the matching file in\n[templates/](templates/README.md). The README says which template fits and how the snippets\nshared between integration pages work.\n\n## Before you write\n\nFind the facts yourself, in the code and the running product, before you draft anything. Don't\nask a question that the code can answer. Ask only about what the code can't tell you, such as\nwhy a feature exists or who it's for.\n\nCheck every fact against the product, not against other docs pages, because other docs pages\nare often out of date:\n\n- **UI steps:** Take button, tab, and field names and the order of screens from the running\n  product, or from the frontend source if you can't reach the running product\n- **Behavior claims:** Read the code path before you describe what the product does; if\n  another docs page seems to contradict the code, find out why before you edit either page\n\n## Reviewing\n\nReview your own drafts the same way you review anyone else's. Work in the following order:\ncut first, then check the structure, then check the sentences, then review the edited page\nagain. Rewriting a sentence is wasted work if the sentence shouldn't be on the page, or if it's\nin the wrong section.\n\n**1. Run the linter.** Run `make lint-docs-branch` from the repository root. Every rule except\n`Infisical.EmDashes` reports at error level, so any other finding fails the command, including\n`Infisical.UIActions` and `Infisical.Contractions`, the two rules most likely to report problems.\n`Infisical.EmDashes` reports at warning level and doesn't change the exit code, so also read the\nwarnings the command prints.\n\nVale doesn't check text indented four or more spaces inside a Mintlify component, and about\nhalf of the text in this repository is indented that way. If Vale reports no problems on a\npage with nested components, the nested text still needs the checks in steps 2, 4, and 5.\n\n**2. Cut what the reader won't act on.** Read the page one paragraph at a time. For each fact,\nask what the reader would do differently after reading it, as \"Leave out what the reader won't\nact on\" in section 2 of the guide describes. If the answer is nothing, the fact is a finding,\neven if it's true and you checked it in the code.\n\nNone of the other steps catches this. A sentence that shouldn't be on the page can still pass\nevery rule in section 5, and if you restructure it instead of cutting it, the page gets more\nheadings and lists without getting any shorter.\n\nApply this step to everything on the page: content that was already there, content someone else\nwrote, and your own draft. Treat each cut like any other finding.\n\n**3. Check the structure against sections 1, 4, 6, 7, 8, and 10 of the guide.** Check every\nsection of the page against each of those sections of the guide, including the page sections\nthat look fine at first glance.\n\n**4. Check every sentence against sections 3 and 5 of the guide.** Go through the sections one\n`###` heading at a time. Each heading names one rule, so for each heading, read every sentence\non the page and check whether the sentence breaks that rule. Then read every sentence out loud,\nas section 5 describes.\n\n**5. Check the formatting against section 11 of the guide.** No automated check covers any\nrule in section 11, so check each rule by reading the page.\n\nBefore you hand a draft back, check the draft for every mistake you've already been corrected\non, including corrections earlier in the same conversation.\n\n**6. Apply the findings.** Edit the page to fix every finding. Don't commit anything.\n\nIf the person you're working with wrote or edited the prose themselves in this conversation,\ndon't change their sentences yet. Report those findings in the format in step 8, and ask once\nwhether to apply them.\n\n**7. Review the edited page.** Run steps 1 to 5 again on the whole edited page, from the top.\nYour own edits create new problems. For example, turning part of a paragraph into a list can\nleave several short paragraphs in a row, and moving content can put a section out of order.\nFix what you find before you hand the page back.\n\n**8. Report what you changed.** Write one change per line: the location as\n`path/to/file.mdx:12`, the rule the change fixes, the text before the change, and the text\nafter it, or \"cut\" for a finding from step 2. Group the changes by file, and include the fixes\nfrom step 7.\n\n## When Vale is wrong\n\n`docs/CONTRIBUTING.MD` describes how to add a word to the vocabulary, enforce a spelling, and\nturn off a rule for one line with `{/* vale Infisical.RuleName = NO */}`. Turn off a rule only\non a specific line where Vale is actually wrong, never to hide problems across a whole page.\n\n<!-- File: .agents/skills/docs-style/templates/README.md (Tokens: ~1506 | Category: anthropic-skill) -->\n# Docs page templates\n\nEach template gives the section order, components, and fixed wording for one type of page.\n\n| Template | Use for | Lives at |\n| --- | --- | --- |\n| [app-connection.mdx](app-connection.mdx) | An app connection guide | `docs/integrations/app-connections/{product}.mdx` |\n| [secret-sync.mdx](secret-sync.mdx) | A secret sync guide | `docs/integrations/secret-syncs/{product}.mdx` |\n| [secret-rotation.mdx](secret-rotation.mdx) | A secret rotation guide | `docs/documentation/platform/secret-rotation/{rotation}.mdx` |\n| [quickstart.mdx](quickstart.mdx) | A quickstart or task guide that ends in a working result | Varies |\n| [landing-page.mdx](landing-page.mdx) | The overview page at the top of a docs section | `{section}/overview.mdx` |\n| [concept-page.mdx](concept-page.mdx) | A page about one concept and the tasks that involve it | Varies |\n\n## How to read a template\n\nEvery template uses the same four kinds of markup:\n\n| Markup | Meaning | What to do |\n| --- | --- | --- |\n| Plain text | Fixed wording | Keep the wording if the wording is true for this product. Check every claim in it against the product first |\n| `{Description}` | A placeholder | Replace the whole placeholder with your own text. The placeholder describes what goes there; it never contains text to copy |\n| `{/* IF: condition */}` ... `{/* END IF */}` | A conditional block | Keep the block, without the two markers, only if the condition is true. Otherwise delete the block |\n| `{/* NOTE: ... */}` | Guidance for the writer | Follow the guidance, then delete the comment |\n\nBraces inside a code span, such as `` `{{secretKey}}` ``, are literal text the product uses, not\nplaceholders.\n\nBefore you publish, delete every `{/* ... */}` comment and search the page for `{` to find any\nplaceholder you missed.\n\n## How closely to follow a template\n\nA template is the default structure for a page type, not a form to fill in:\n\n- **Leave out what the product doesn't have.** If a sync has no destination fields, or a\n  quickstart has no second sub-task, delete that part rather than writing filler for it\n- **Add what the product needs.** If the product needs a step or a section the template doesn't\n  have, add it where a reader would look for it (STYLE_GUIDE.md section 7)\n- **Check the fixed wording.** Fixed wording is true for most products of the type, not all of\n  them. Button labels, field names, and behavior claims such as \"The name must be\n  slug-friendly\" come from one version of the product, so check each one against the frontend\n  or backend before keeping it\n- **Write your own sentences.** The templates contain no example sentences on purpose. For what\n  a finished page looks like, read the example pages named at the top of each template, but\n  don't copy their sentences, which are about a different product\n\n## Shared snippets for an integration\n\nAn app connection page, a sync page, and a rotation page for the same product render the same\nsetup steps and form fields, so those live in snippets under\n`docs/snippets/app-connections/{product}/`:\n\n- `form-fields.mdx`: a bullet list of the connection form's fields, starting with **Name** and\n  **Description** (optional)\n- `{setup}.mdx`, named after the setup task in the third-party product: a `<Steps>` block for\n  that setup, with screenshots\n- `{setup}-for-rotation.mdx`: only when a rotation needs more setup than the connection does;\n  import the base snippet and add the extra steps rather than copying the base steps\n\nA link in `form-fields.mdx` to a step in the setup snippet resolves on whichever page renders\nboth snippets. Link to the `## Step 1:` heading that every page puts the setup snippet under,\nand give that heading the same text on the connection, sync, and rotation pages. Linking to a\nstep title doesn't work, because steps don't have a `title` prop.\n\nLinks from a sync or rotation page to the organization-level connection steps use\n`/integrations/app-connections/{product}#step-2-create-the-app-connection`.\n\n## Steps that depend on the integration\n\nWhen a connection needs different setup for each integration that uses it, such as a separate\npermission policy per integration, put a use case picker at the top of the page instead of a tab\nper integration. `snippets/UseCasePicker.jsx` exports two components:\n\n- `<UseCasePicker prefix=\"I want my {Product} connection to work for\" groups={...} />`: a\n  sentence ending in a dropdown where the reader picks one or more integrations\n- `<UseCase use=\"{id}\">`: shows its children only when the reader picked that id, or when they\n  picked nothing, so the page reads as the complete guide by default. `use` takes a\n  comma-separated list for content that several integrations share. Add `hideWhenEmpty` for\n  content that only makes sense next to a filtered result, such as setup notes that follow a\n  combined policy, so it stays hidden until the reader picks something\n\nA `<UseCase>` can hold its own `###` heading. Mintlify builds the \"On this page\" list at build\ntime, so it would still list hidden headings; the picker hides those entries at runtime, so the\nlist matches what's on the page\n\nPut the options in `snippets/app-connections/{product}/use-cases.jsx` as an exported array of\n`{ label, options: [{ id, label }] }` groups, import it on the page next to the component, and\npass it as `groups`. A snippet can't import another snippet, so the page does both imports. The\npicker writes the selection to the URL (`?use=id-1,id-2`), so a reader can share a link to a\nfiltered page.\n\nWrap each integration's content in its own `<UseCase>`, including its card in the\n\"Configure integrations\" step. `integrations/app-connections/aws.mdx` is the example.\n\n## Where the example pages differ from the templates\n\nThe example pages each template names predate some of the style guide's rules. When you read\nan example page, don't copy these:\n\n- Titled steps (`<Step title=\"...\" titleSize=\"h3\">`); the guide says to leave out `title`\n- Periods at the end of bullets\n- Em dashes, such as \"This guide doesn't configure any behavior—it just authenticates\"\n- Button labels in the 1Password and Databricks sync and rotation pages, which come from older\n  versions of the wizards (**Next** instead of **Continue**, **Disable Secret Deletion**\n  instead of **Prevent secret deletion**)\n\n<!-- File: .agents/skills/testing-secrets-ui/SKILL.md (Tokens: ~691 | Category: anthropic-skill) -->\n---\nname: testing-secrets-ui\ndescription: Test the Infisical secrets management UI end-to-end. Use when verifying secret CRUD operations, URL encoding fixes, or frontend API layer changes.\n---\n\n# Testing Secrets Management UI\n\n## Prerequisites\n\n- Docker and Docker Compose installed\n- The repo cloned locally\n\n## Setting Up Local Dev Environment\n\n1. Start the full stack:\n   ```bash\n   make up-dev\n   ```\n   This runs `docker-compose.dev.yml` which starts PostgreSQL, Redis, backend (Fastify), frontend (React/Vite), and Nginx.\n\n2. Wait for all containers to be healthy. The app runs on `http://localhost:8080`.\n\n3. **Common issue: Migration lock**\n   If the backend fails to start with \"Migration table is already locked\", unlock it:\n   ```bash\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_lock SET is_locked = 0;\"\n   docker exec -i infisical-dev-db psql -U infisical -d infisical -c \"UPDATE infisical_migrations_startup_lock SET is_locked = 0;\"\n   docker restart infisical-dev-api\n   ```\n   Wait ~15 seconds for migrations to complete.\n\n4. Create a test account at `http://localhost:8080/signup` (local dev has no email verification).\n\n5. Create a test organization and project after signup.\n\n## Testing Secret CRUD Operations\n\n### Create Secret\n- Navigate to a project's Secret Dashboard (Project Overview page)\n- Click \"+ Add Secret\" to open the Create Secret modal\n- Fill in Environment (defaults to Development), Key, and Value\n- Click \"Create Secret\"\n- The modal uses the `createSecret` function from `frontend/src/hooks/api/secrets/mutations.tsx`\n\n### Update Secret (Inline Edit)\n- On the dashboard, click the secret's key field to edit it inline\n- Change the key or value\n- Click the save icon (floppy disk) that appears\n- This uses the `useUpdateSecretV3` mutation from `mutations.tsx`\n\n### Testing URL Encoding\nWhen testing that special characters in secret keys are handled correctly:\n- Try creating/updating secrets with `/`, `%`, `#`, `?`, `&` in the key name\n- Verify the browser network tab shows the key is URL-encoded (e.g., `/` becomes `%2F`)\n- Check backend logs: `docker logs infisical-dev-api 2>&1 | grep \"secret\"` to confirm the encoded URL\n- The error should be a \"Validation Error\" (Fastify param validation), NOT \"Route not found\"\n\n## Key Files\n\n- `frontend/src/hooks/api/secrets/mutations.tsx` — Secret create/update/delete mutations\n- `frontend/src/hooks/api/secrets/queries.tsx` — Secret query hooks\n- Backend logs: `docker logs infisical-dev-api`\n- Database: `docker exec -it infisical-dev-db psql -U infisical -d infisical`\n\n## Devin Secrets Needed\n\nNo external secrets are required for local dev testing. The local environment uses:\n- Database: PostgreSQL with user `infisical` (configured in docker-compose.dev.yml)\n- All services run locally via Docker\n\n<!-- File: backend-go/CLAUDE.md (Tokens: ~3247 | Category: claude-rule) -->\n# backend-go\n\nPartial Go rewrite of the Node.js backend using chi + oapi-codegen + raw pgx queries.\n\n## Commands\n\n```\nmake build            # build binary\nmake dev              # hot-reload via docker-compose + air\nmake test             # run all tests (unit + integration)\nmake test-unit        # unit tests only (./internal/...)\nmake test-integration # integration tests only (./tests/..., testcontainers)\nmake test-hsm         # HSM tests (requires Docker, builds test container)\nmake lint             # must pass before submission\nmake lint-fix         # auto-fix linting issues\nmake generate         # run go generate ./... for oapi-codegen\n```\n\n## Code Rules\n\n- **Linting**: `make lint` must pass. No `//nolint` without justification.\n- **Service constructor**: `(ctx context.Context, logger *slog.Logger, deps *Deps)`. Deps struct ends in `Deps`, passed by pointer. Every I/O method takes `ctx` first.\n- **Logger**: pass `*slog.Logger` via constructor — never `slog.Default()`.\n- **Interfaces**: consumer-defined. Accept interfaces, not concrete types.\n- **Visibility**: expose only what's needed.\n- **No DAL layer**: services use `pg.DB` directly with raw pgx.\n- **Error wrapping**: wrap DB errors at service level with context:\n  ```go\n  errutil.DatabaseErr(\"Failed to load\").WithErrf(\"FuncName(arg=%s): %w\", arg, err)\n  errutil.Forbidden(\"Access denied\").WithErrf(\"FuncName: permission check failed\")\n  ```\n- **Lean code**: inline single-use helpers.\n- **Test naming**: `Test<FunctionName>_<Scenario>`.\n\n## Architecture\n\n```\ncmd/infisical/main.go           # Entry point\ninternal/\n├── config/                     # Env config via koanf\n├── database/\n│   ├── pg/                     # DB interface (primary + replicas via pgxpool)\n│   │   ├── qb/                 # Query builders (Where, Insert, Update, Delete)\n│   │   ├── sqln/               # SQL nesting (GroupRows for LEFT JOIN flattening)\n│   │   └── pglock/             # PostgreSQL advisory locks\n│   └── redis/                  # Redis client\n├── libs/                       # crypto, errutil, fn, logutil\n├── server/\n│   ├── api/                    # Endpoint implementations + DI wiring\n│   │   ├── shared/             # Shared types (Error, ValidationError) + ErrorHandler\n│   │   ├── platform/           # Platform handlers (projects, etc.)\n│   │   └── secretmanager/      # Secret manager handlers\n│   └── middlewares/            # HTTP middlewares (RequireAuth, etc.)\n├── services/                   # Shared business logic (auth, permission, kms, ...)\n└── keystore/                   # Redis key-value operations\ntests/                          # Integration tests (external test packages)\n├── infra/                      # Test infrastructure (testcontainers, helpers)\n├── platform/                   # Platform service tests\n│   ├── auth/                   # Authentication handler tests\n│   ├── externalkms/            # External KMS (AWS/GCP) tests\n│   ├── hsm/                    # HSM tests (container-based)\n│   ├── kms/                    # KMS service tests\n│   ├── permission/             # Permission system tests\n│   ├── projects/               # Projects handler tests\n│   └── ratelimit/              # Rate limiting tests\n└── secretmanager/\n    └── secrets/                # Secrets API tests (list, get, permissions)\n```\n\n**Two tiers:**\n- `server/api/` — DI wiring + endpoint implementations. Handlers 1:1 with endpoints.\n- `services/` — Business logic, uses `pg.DB` directly.\n\n### Wiring (Composition Root)\n\nAll service/handler initialization lives in `server/api/`:\n\n```\nserver/api/\n├── api.go                         # Infra, Registry, NewRegistry()\n├── platform_services.go           # newPlatformServices() — kms, auth, permission, etc.\n├── secretmanager_services.go      # newSecretManagerServices() — secret, folder, import\n├── platform_routes.go             # RegisterPlatformRoutes() — chi routing\n├── secretmanager_routes.go        # RegisterSecretManagerRoutes() — chi routing\n├── shared/                        # Shared types + error handler\n├── platform/<handler>/            # Handler implementations\n└── secretmanager/<handler>/       # Handler implementations\n```\n\nInitialize as local variables first, then assign to struct fields.\n\n### Handler Package Structure\n\nEach handler package is generated from an OpenAPI spec using `oapi-codegen`:\n\n```\nserver/api/<product>/<handler>/\n├── openapi.yml    # OpenAPI 3.0 spec (source of truth)\n├── cfg.yaml       # oapi-codegen config\n├── gen.go         # GENERATED types, HTTPAdapter, ServiceInterface\n├── <name>.go      # Handler struct + NewHandler() + //go:generate directive\n└── <endpoint>.go  # One handler method per file (list_secrets.go, get_secret.go, ...)\n```\n\n### Handler Pattern\n\nUses `oapi-codegen` (DoorDash fork) to generate types and HTTP adapters from OpenAPI specs:\n\n**`openapi.yml`** — OpenAPI 3.0 source of truth:\n```yaml\npaths:\n  /api/v4/secrets:\n    get:\n      operationId: ListSecretsV4\n      summary: List secrets\n      parameters:\n        - name: projectId\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: environment\n          in: query\n          required: true\n          schema:\n            type: string\n        - name: secretPath\n          in: query\n          schema:\n            type: string\n      responses:\n        '200':\n          content:\n            application/json:\n              schema:\n                $ref: '#/components/schemas/ListSecretsV4Response'\n```\n\n**`<handler>.go`** — Handler implements generated `ServiceInterface`:\n```go\n//go:generate go tool oapi-codegen -config cfg.yaml openapi.yml\n\npackage secret\n\nvar _ ServiceInterface = (*Handler)(nil)\n\ntype Handler struct {\n    logger     *slog.Logger\n    permission PermissionService\n    secrets    SecretsService\n}\n\nfunc NewHandler(deps *Deps) *Handler {\n    return &Handler{...}\n}\n\n// ListSecretsV4 implements ServiceInterface\nfunc (h *Handler) ListSecretsV4(ctx context.Context, opts *ListSecretsV4ServiceRequestOptions) (*ListSecretsV4ResponseData, error) {\n    q := opts.Query\n    secretPath := fn.ValueOr(q.SecretPath, \"/\")  // Use helper for optional fields\n    \n    // ... business logic ...\n    \n    return NewListSecretsV4ResponseData(&ListSecretsV4Response{\n        Secrets: secrets,\n    }), nil\n}\n```\n\n**Generated types:**\n- Required fields: plain types (`string`, `int`, etc.)\n- Optional fields: pointers (`*string`, `*int`, etc.)\n- Use `fn.ValueOr(ptr, default)` for safe access with fallback\n- Use `new(value)` to create pointers for optional fields (Go 1.26+)\n\n**Route registration** (`<product>_routes.go`):\n```go\nfunc RegisterSecretManagerRoutes(router chi.Router, logger *slog.Logger, platform *PlatformServices, svc *SecretManagerServices) {\n    secretsHandler := secret.NewHandler(&secret.Deps{...})\n    secretsAdapter := secret.NewHTTPAdapter(secretsHandler, shared.NewErrorHandler(logger))\n\n    router.Group(func(r chi.Router) {\n        r.Use(middlewares.RequireAuth(\n            platform.Authenticator,\n            middlewares.JWTAuth,\n            middlewares.IdentityAccessTokenAuth,\n            middlewares.ServiceTokenAuth,\n        ))\n        r.Get(\"/api/v4/secrets\", secretsAdapter.ListSecretsV4)\n        r.Get(\"/api/v4/secrets/{secretName}\", secretsAdapter.GetSecretByNameV4)\n    })\n}\n```\n\n### Auth Middleware\n\n`RequireAuth` is a fail-closed middleware that validates bearer tokens:\n\n```go\nr.Use(middlewares.RequireAuth(\n    authenticator,\n    middlewares.JWTAuth,              // User session JWT\n    middlewares.IdentityAccessTokenAuth,  // Machine identity token\n    middlewares.ServiceTokenAuth,     // Service token\n))\n```\n\n- No bearer header → 401\n- Bearer present but mode not in allowed list → 401\n- Validator returns error → 401\n- Success: identity stored in context via `auth.WithIdentity()`\n\n### Error Handling\n\n`shared.ErrorHandler` translates errors to HTTP responses:\n- `errutil.Error` → appropriate status + JSON body\n- `runtime.ValidationErrors` → 400 with field errors\n- Unknown errors → 500\n\nAll errors include request ID and are logged appropriately (warn for 4xx, error for 5xx).\n\n### Handler vs Service Responsibilities\n\n**Handlers** (`server/api/`) — thin orchestration:\n1. Extract identity from context\n2. Resolve permissions + IDs (e.g., project ID from slug) via shared services\n3. Call domain service with opts (including `AccessChecker` for permission filtering)\n4. Build API response + audit logs\n\n**Services** (`services/`) — domain logic: data fetching/aggregation, decryption/encryption, business rules (import chaining, secret expansion, personal overrides), and permission filtering via `AccessChecker` in opts.\n\n**AccessChecker pattern**: services accept an optional `AccessChecker` interface in opts. Pass `nil` to skip permission checks (internal/integration use). Permission logic stays in handlers; services enforce it.\n\n### Permission Checker Pattern\n\nPermission checks use `gocasl`. Instead of calling `gocasl.Can()` directly in handlers/services, wrap each domain's checks in a dedicated checker type under `services/permission/<product>/`:\n\n```go\n// services/permission/project/secret_permission.go\ntype SecretAccessChecker struct {\n    ability *gocasl.Ability\n}\n\nfunc NewSecretAccessChecker(ability *gocasl.Ability) *SecretAccessChecker {\n    return &SecretAccessChecker{ability: ability}\n}\n\nfunc (c *SecretAccessChecker) CanReadSecretValue(env, path, key string, tags []string) bool {\n    return gocasl.Can(c.ability, project.SecretActionReadValue, project.SecretSubject{\n        Environment: env, SecretPath: path, SecretName: key, SecretTags: tags,\n    })\n}\n```\n\nUsage:\n\n```go\npermResult, _ := permissionSvc.GetProjectPermission(ctx, args)\nchecker := project.NewSecretAccessChecker(permResult.Permission.Ability)\nif !checker.CanReadSecretValue(env, path, key, tags) {\n    return errutil.Forbidden(\"Cannot read secret\")\n}\n```\n\n**Why**: keeps subject construction and action selection in one place per domain, makes checkers easy to mock in tests, and prevents handler/service code from depending on `gocasl` directly. Existing checkers: `SecretAccessChecker`, `AssumePrivilegeChecker`.\n\nActions and subjects live in `services/permission/project/{actions.go,subjects.go}`:\n- `gocasl.DefineAction[SubjectType](\"verb\")` — typed action constants\n- Subject structs implement `SubjectType() string` and `GetField(string) any` so CASL can match field-conditional rules\n\n**Database access**: services receive `pg.DB` and execute raw pgx queries via helper packages. Use `db.Primary()` for writes, `db.Replica()` for reads.\n\n### Query Helpers\n\n**`qb`** — dynamic SQL builders:\n\n```go\n// WHERE (named args)\nargs := pgx.NamedArgs{\"folderID\": folderID}\nwhere := qb.NewWhere().\n    Add(\"folder_id = @folderID\").\n    AddIf(len(keys) > 0, \"key = ANY(@keys)\")\nargs[\"keys\"] = keys\nrows, _ := db.Replica().Query(ctx, `SELECT * FROM secrets WHERE `+where.String(), args)\n\n// INSERT / UPDATE / DELETE\nsql, args := qb.Insert(\"secrets\", \"id\", \"key\", \"folder_id\").\n    Values(id1, key1, folder1).Values(id2, key2, folder2).Returning(\"*\").Build()\nsql, args := qb.Update(\"secrets\").Set(\"key\", newKey).Where(\"id = @id\", \"id\", id).Returning(\"*\").Build()\nsql, args := qb.Delete(\"secrets\").Where(\"id = @id\", \"id\", id).Build()\n```\n\n**`sqln`** — flatten LEFT JOIN rows into nested structs:\n\n```go\nsecrets := sqln.GroupRows(flatSecrets, sqln.Grouper[Secret, uuid.UUID]{\n    Key:   func(s *Secret) uuid.UUID { return s.ID },\n    Merge: func(existing, row *Secret) {\n        if len(row.Tags) > 0 {\n            existing.Tags = fn.AppendUnique(existing.Tags, row.Tags[0],\n                func(t Tag) uuid.UUID { return t.ID })\n        }\n    },\n})\n```\n\n**`pglock`** — PostgreSQL advisory locks:\n\n```go\ntx, _ := db.Primary().Begin(ctx)\nlock, err := pglock.AcquireBlockingLock(ctx, tx, \"my_lock_id\")\nif err != nil { tx.Rollback(ctx); return err }\ndefer lock.Rollback(ctx)  // safety net\n// ... work ...\nlock.Release(ctx)         // commits\n```\n\n### Writing Raw Queries\n\nUse `pgx.NamedArgs`. Table aliases should be readable abbreviations (`memberships m`, `membership_roles mr`, `additional_privileges ap`) — never single letters.\n\n```go\nrows, _ := db.Replica().Query(ctx, `\n    SELECT id, key, encrypted_value\n    FROM secrets_v2\n    WHERE folder_id = @folderID AND user_id IS NULL\n    ORDER BY key ASC\n`, pgx.NamedArgs{\"folderID\": folderID})\nsecrets, _ := pgx.CollectRows(rows, pgx.RowToStructByName[Secret])\n```\n\n## Wiring a New Feature\n\n1. **Service** in `services/<product>/<name>/` with interface-based deps.\n\n2. **Handler** in `server/api/<product>/<name>/`:\n   - Create `openapi.yml` with endpoints\n   - Create `cfg.yaml` from template (see existing handlers)\n   - Run `go generate ./...` to create `gen.go`\n   - Create `<handler>.go` with `//go:generate` directive, `Handler` struct, `NewHandler()`\n   - Implement `ServiceInterface` methods (one file per endpoint)\n\n3. **Permission checks** (if needed): add action constants in `services/permission/<product>/actions.go`, subject struct in `subjects.go`, and a checker in `<domain>_permission.go`.\n\n4. **Wire routes** in `server/api/<product>_routes.go`:\n   - Create handler and HTTP adapter\n   - Mount routes with appropriate auth middleware\n\n5. **Register** service in `server/api/<product>_services.go`.\n\n6. Run `make test && make lint`.\n\n<!-- File: backend/CLAUDE.md (Tokens: ~11768 | Category: claude-rule) -->\n# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **backend** package of the Infisical monorepo — a Fastify 4 API server with TypeScript, PostgreSQL via Knex, and BullMQ queues.\n\nThe backend code quality guide is imported below, so it is always in context for work in this package. **Check every change against it, whatever the change is, before calling the task done.** This file covers *where* code goes and *which* pattern to follow; the quality guide covers the minimum any implementation has to handle. It is a deliberately non-exhaustive floor, so the topics it happens to cover are not a filter for whether it applies.\n\n@CODE_QUALITY.md\n\n## Essential Commands\n\nAll commands run from the `backend/` directory:\n\n- `npm run dev` — start dev server with tsx watch + pino-pretty logging (`dev:docker` is the same with the debugger on 9229)\n- `npm run build` — production build via tsdown (`tsdown.config.ts`) with sourcemaps\n- `npm run lint:fix` — ESLint autofix\n- `npm run type:check` — TypeScript check (uses 8GB heap)\n- `make reviewable-api` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n\nThe backend runs on Node 26. TypeScript files run directly through tsx (dev server, scripts, the\n`*-dev` knex commands, and the e2e environment's migration loading); there is no ts-node.\n\n### Build (tsdown)\n\n`tsdown` compiles every module to its own `dist/**/*.mjs` (unbundled ESM) and rewrites `@app/*` to\nrelative paths itself. Invariants that a config change can silently break:\n\n- **The output extension stays `.mjs`.** knex records each migration's file name, extension included, in\n  `infisical_migrations`, so production rows are `*.mjs`. A different extension makes every applied\n  migration look unknown and the boot check refuses to start.\n- **`main` stays the first entry, and entry paths stay absolute.** Rolldown orders each file's imports by\n  execution order, walking entries in order; a glob that sorts another entry ahead of `main` hoists its\n  imports above the telemetry instrumentation. Object entries ignore `root`, so relative paths nest every\n  other module under `dist/src/`.\n- **Rolldown reorders imports, even within one file.** Never rely on import order for a global side effect\n  that another package needs; import it explicitly where it is needed (see `reflect-metadata` in\n  `lib/crypto/pqc/pqc-algorithm.ts`).\n- **`deps.onlyBundle: []` fails the build if a `node_modules` package would be inlined.** That means\n  `src/` imports a package that `package.json` does not declare; declare it instead of loosening the guard.\n\n### Testing\n\nRun both suites from the repo root, which is the same entry point CI uses:\n\n- `make test-api-unit` — unit tests matching `./src/**/*.test.ts`\n- `make test-api-e2e` — e2e tests matching `./e2e-test/**/*.spec.ts`\n- `make test-api-e2e SPEC=<pattern>` — narrow to one spec, e.g. `SPEC=secret-sync`\n- `make up-rotation-databases` — start the databases the secret rotation specs need, required\n  only for a full run (`docker-compose.e2e-dbs.yml`, and the Oracle image is large)\n- `make down-test-suite-containers` — stop everything when finished\n\nThe secret rotation specs reach their databases by compose service name, so\n`docker-compose.e2e-dbs.yml` pins the same compose project as the test stack and shares its\nnetwork. Without `make up-rotation-databases`, those specs fail on connection; the rest pass.\n\nBoth run inside the FIPS image (`Dockerfile.dev.fips`), which carries the native dependencies\nthe suites need (SoftHSM2, the Oracle client, the FIPS OpenSSL build) and pins the Node\nversion, so the host's does not matter. `docker-compose.test.yml` declares the image,\nenvironment, mounts and services; `src`, `e2e-test` and both vitest configs are mounted, so\nediting a test needs no rebuild but changing `package.json` or `tsconfig.json` does.\n\n**`npm run test:e2e` directly is possible but destructive if misconfigured.** The Vitest\nenvironment runs `DROP SCHEMA public CASCADE` on whatever database `.env.test` points at,\nbefore every run. Copy `.env.test.example`, which targets the throwaway stack, and start it\nwith `make up-test-suite-containers`. Never point it at the dev database.\n\nUnit tests go next to source as `*.test.ts` and test pure functions with Vitest globals (`describe`, `test`, `expect`).\n\nE2E tests live in `e2e-test/routes/`. The custom Vitest environment (`e2e-test/vitest-environment-knex.ts`) bootstraps a full server with DB, Redis, and encryption. Tests use injected globals: `testServer` (Fastify instance), `jwtAuthToken` (pre-authenticated JWT). Use `testServer.inject()` for HTTP assertions. Test helpers in `e2e-test/testUtils/` provide CRUD wrappers for secrets, folders, and secret imports. See `e2e-test/routes/v1/org.spec.ts` for a representative e2e test.\n\n#### Writing e2e tests with `testUtils`\n\n**Build test state with the helpers in `e2e-test/testUtils/`, not by hand.** Before writing a\n`testServer.inject` call or a `testDb` insert to set something up, check `testUtils/` for a helper\nthat already does it. If none exists, add one there rather than a local helper in the spec: two\nspecs had each written their own `createTargetUser` before `testUtils/users.ts` existed, and every\ncopy drifts on its own.\n\n- **Isolate each test.** `createIsolatedOrgAndProject` (`fixtures.ts`) gives a test its own org and\n  project, a token scoped to it, and a `cleanup` that deletes the org. Prefer it over the shared\n  `seedData1` org and project, whose state leaks between tests and files.\n- **Don't lean on the seed user as a participant.** When a test needs someone other than the caller\n  (a recipient, a member, a target), create them with `createUser` and `addUserMembership`\n  (`users.ts`). Those write rows directly because signup needs SRP, so the user can be named but\n  cannot log in.\n- **Go through the API for everything else**, with the domain helpers (`secrets.ts`, `folders.ts`,\n  `alerts.ts`, `reminders.ts`, `identities.ts`, ...). Write a helper with `request()` (`request.ts`)\n  so a call is sent when made and a caller can `.expect()` a non-200 or take the `.raw()` response.\n- **Assert on outcomes a user would see**: an API response, an email in `testSmtp`, a message at a\n  fake destination. Read `testDb` only when the API cannot show the thing under test (eg an alert\n  orphaned by a cascade), and say why in a comment.\n- **Wait with `pollUntil`** (`poll.ts`) for anything asynchronous, never a fixed sleep.\n- **Run background jobs on demand** through a pass-through wrapper in `e2e-test/fakes/` (eg\n  `reminder-queue.ts` and `event-outbox-queue.ts`, driven by `runDailyReminders` in `reminders.ts`).\n\n#### Faking a third-party provider\n\n**Never add test-only code to `src/`** — no test-mode enum members, no lookup map entries, no\n`isTestMode` branches. Replace the module instead, from `test.alias` in\n`vitest.e2e.config.mts`, with a double under `e2e-test/fakes/`. Production code stays unaware\na fake exists. `e2e-test/fakes/aws-parameter-store-sync-fns.ts` and its connection counterpart\nare the worked examples, and the pre-existing `./license-fns` alias is the precedent.\n\nFour things decide whether this works:\n\n- **Alias the narrowest specifier.** Entries match the import string, so aliasing one a single\n  file imports (a barrel's `./x-fns` re-export) swaps that seam and leaves the constants,\n  schemas, types, router and lookup maps real.\n- **`test.alias` must stay an array.** Vite's `mergeAlias` concatenates arrays with\n  `test.alias` first, but merges two objects, where the generic `@app` prefix matches before a\n  specific `@app/...` entry and the fake silently stops applying with no error.\n- **Re-export whatever you do not replace.** The alias swaps the whole module, so an export you\n  omit stops existing for every importer of it.\n- **Assert the fake still matches.** Nothing otherwise checks it against the module it\n  replaces. Export an assignment typed as `Pick<typeof RealModule, …>` so a signature change\n  fails type-checking rather than leaving the fake quietly wrong.\n\nA fake must reproduce the contract under test, not merely record its input. The Parameter Store\nfake reimplements the real reconciliation rules (skip empty writes, delete absent keys unless\ndeletion is disabled, respect the key schema), because those rules are the behavior the specs\nexist to pin.\n\n#### FIPS test image and the prebuilt toolchain\n\nCI (`.github/workflows/run-backend-tests.yml`) runs the e2e suite inside a FIPS image built\nfrom `Dockerfile.dev.fips`. The expensive, rarely-changing parts of that image (SoftHSM2,\nOracle Instant Client, the FIPS OpenSSL 3.1.2 build and the PQC OpenSSL 3.5.6 build) live in\n**`Dockerfile.fips-toolchain`**, which is published to\n`ghcr.io/infisical/backend-fips-toolchain` for amd64 and arm64 by\n`.github/workflows/publish-fips-toolchain.yml` on pushes to `main`.\n\n`Dockerfile.dev.fips` consumes it via the `TOOLCHAIN_IMAGE` build arg, so CI and local dev\npull the toolchain instead of spending ~15min compiling it. Consequences worth knowing:\n\n- **Editing `Dockerfile.fips-toolchain` is expensive.** CI pins the image by the content hash\n  of that file, so a PR touching it has no published tag and rebuilds from source (the old,\n  slow path). It publishes once the PR lands on `main`. Keep app-level changes in\n  `Dockerfile.dev.fips`.\n- **Don't add `cache-to: type=gha` back to the image build.** GHA cache is PR-scoped (so it\n  never hits on a PR's first run) and the repo's 10GB cache budget is already full; writing\n  image blobs there evicts the `node_modules` caches of every workflow.\n- **The published image is an internal CI build cache, not a supported artifact.** It is\n  public only so CI and local dev can pull it; it carries no support or compatibility\n  guarantee. It is *not* the FIPS product image; that one is `infisical/infisical-fips`,\n  built from `Dockerfile.fips.standalone-infisical`. OCI labels on the image say the same.\n- **Keep every third-party fetch pinned and checksummed.** SoftHSM2 is pinned to a commit\n  SHA (tags are mutable); both OpenSSL tarballs and the Oracle Instant Client zip are\n  SHA256-verified; the Infisical CLI apt package is version-pinned. Oracle Instant Client\n  is redistributed under the Oracle Free Distribution, Hosting, and Use Terms, which\n  require the license to travel with it, so `/opt/oracle/instantclient_23_26/BASIC_LICENSE`\n  must not be removed.\n\n##### Building the dev stack without GHCR\n\n`docker compose -f docker-compose.dev.yml build backend` pulls the toolchain from GHCR by\ndefault. If GHCR is down, the package is private, you're offline, or you're iterating on the\ntoolchain itself, build it locally once and point the compose build at it:\n\n```bash\n# 1. Build the toolchain from source (~15min, or ~2min if your layer cache is warm)\ndocker build -f backend/Dockerfile.fips-toolchain -t fips-toolchain:local backend\n\n# 2. Prefer .env, so it survives every compose invocation (see the warning below)\necho 'TOOLCHAIN_IMAGE=fips-toolchain:local' >> .env\n\n# ...or pass it per-invocation. Both forms work but are easy to forget:\nTOOLCHAIN_IMAGE=fips-toolchain:local docker compose -f docker-compose.dev.yml build backend\ndocker compose -f docker-compose.dev.yml build --build-arg TOOLCHAIN_IMAGE=fips-toolchain:local backend\n```\n\n**Use `.env` rather than a one-off env var.** The documented start command in\n`docs/contributing/platform/developing.mdx` is `up --build`, which rebuilds the backend and\nre-resolves the `TOOLCHAIN_IMAGE` default, so a per-invocation override silently stops\napplying and you're back to a failing GHCR pull. Compose interpolates the project `.env` for\nevery invocation, so putting it there sticks.\n\nNotes:\n\n- Step 1 is only needed once per change to `Dockerfile.fips-toolchain`. The local tag name is\n  arbitrary; it just has to match what you point `TOOLCHAIN_IMAGE` at.\n- An already-built `backend` image keeps working if GHCR is unreachable, as long as you don't\n  pass `--build`. This only bites on a rebuild.\n- This is not an offline story. The toolchain build itself still fetches Debian packages, both\n  OpenSSL tarballs, SoftHSM sources, and the Oracle client. With no network at all, neither\n  path works and you need a previously built image.\n\n### Database\n\n- `npm run migration:new` — create new migration (interactive prompt)\n- `npm run migration:latest-dev` — run pending migrations (dev, uses `src/db/knexfile.ts`)\n- `npm run migration:rollback-dev` — rollback last migration batch (dev)\n- `npm run generate:schema` — regenerate Zod types from DB into `src/db/schemas/` (always run after migration changes)\n- `npm run seed-dev` — run database seeds\n\n- **Every foreign-key column must be covered by an index** (its own, or as the leftmost column of a composite index). Postgres does **not** auto-index FK columns. This holds regardless of cascade behavior: an unindexed FK forces a seq-scan of the child table on every parent `DELETE`/`UPDATE` (per-row RI trigger), and the FK column is almost always also a join/filter key. Watch helper-generated FKs — `createJunctionTable` (`src/db/utils.ts`) creates CASCADE FK columns with **no index** on either side.\n\n### Scaffolding\n\n- `npm run generate:component` — interactive generator that can create a service module (DAL + service + types), a standalone DAL, or a router\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## ESLint & Import Ordering\n\nConfig in `.eslintrc.js`. Uses `simple-import-sort` with this group order:\n1. Side-effect imports\n2. `node:` builtins\n3. Third-party packages\n4. `@app/` imports\n5. `@lib/` imports\n6. `@server/` imports\n7. Relative imports\n\n## Architecture\n\n### Service Factory + Manual DI\n\nNo IoC container. Every service is a factory function that receives explicit dependencies as a typed object and returns an object of methods. Dependencies are narrowed with TypeScript `Pick` to define minimal interface contracts.\n\nThe entire dependency graph is manually wired in `src/server/routes/index.ts`:\n- **~line 480-646**: DAL instantiation — each DAL factory receives the `db` client\n- **~line 649-2800**: Service instantiation — each factory receives its DALs + other services\n- **~line 2831-2972**: `server.decorate(\"services\", {...})` exposes ~100+ services as `server.services.*`\n- **~line 3082-3098**: Route registration — EE routes registered before community routes per API version\n\nSee `src/services/secret/secret-service.ts:104-162` for a representative factory with ~20 dependencies. See `src/services/user/user-service.ts` for a simpler example (~8 dependencies).\n\n### DAL Layer (Data Access)\n\nEach service has a `*-dal.ts` that wraps `ormify()` (defined in `src/lib/knex/index.ts:155-326`). `ormify()` provides typed CRUD methods: `findById`, `find`, `findOne`, `create`, `insertMany`, `batchInsert`, `upsert`, `updateById`, `update`, `deleteById`, `delete`, `countDocuments`, `transaction`.\n\nDALs extend these with custom queries — typically complex joins using Knex query builder and `sqlNestRelationships()` for nested entity mapping. All methods accept an optional `tx` parameter for transaction threading.\n\n**Read replica pattern**: Read methods use `db.replicaNode()` (e.g., `(tx || db.replicaNode())(tableName)`), while writes always hit the primary (`(tx || db)(tableName)`). See any `ormify()` method in `src/lib/knex/index.ts` for the pattern.\n\n`updateById` and `update` support atomic `$incr` and `$decr` operators alongside regular field updates.\n\n**A dropped column does not fail type checking if the insert is built in a `.map()`.** TypeScript's\nexcess-property check only fires on a fresh object literal at the assignment site, so a stale field\nsurvives when the literal is returned from a callback:\n\n```ts\ninsertMany(names.map((name) => ({ name, role: \"member\", orgId })));  // compiles, then 500s at runtime\ninsertMany([{ name, role: \"member\", orgId }]);                       // TS2353\n```\n\nSee `src/services/secret/secret-dal.ts` for a DAL that overrides the base `update` to auto-increment version and adds complex join queries.\n\n### Service Module Structure\n\nServices live in `src/services/` (100+ modules). Each typically contains:\n- `*-dal.ts` — data access via `ormify()` + custom queries\n- `*-service.ts` — business logic factory\n- `*-types.ts` — DTOs and type definitions\n- `*-queue.ts` — BullMQ async job handlers and/or cron-manager registrations (when needed)\n- `*-fns.ts` — pure utility functions (when needed)\n\n### Route Handler Pattern\n\nRoutes use Fastify's Zod type provider — schemas auto-generate OpenAPI docs. Each route specifies: `method`, `url`, `config.rateLimit` (using `readLimit` or `writeLimit` presets), `schema` (Zod schemas with `operationId` for OpenAPI), `onRequest: verifyAuth([AuthMode.*])`, and a `handler` that accesses business logic via `server.services.*`.\n\nSee `src/server/routes/v4/secret-router.ts` for a representative router file.\n\n### Auth System\n\nAuth extraction happens in `src/server/plugins/auth/`:\n- `inject-identity.ts` — detects auth mode from request headers and attaches identity to request\n- `verify-auth.ts` — middleware that checks if the request's auth mode is in the route's allowed strategies\n- `inject-permission.ts` — attaches permission context\n- `inject-assume-privilege.ts` — privilege escalation support\n- `superAdmin.ts` — super admin flag injection\n\n**Auth modes** (defined in `AuthMode` enum):\n- **JWT** — user browser sessions (decoded from `Authorization: Bearer` header)\n- **IDENTITY_ACCESS_TOKEN** — machine-to-machine identity tokens\n- **SCIM_TOKEN** — SCIM provisioning tokens\n- **OAUTH** — delegated user tokens from the `oauth-client` module. Same JWT shape and session binding as\n  `JWT`, told apart by an `oauthClientId` claim. See [Delegated OAuth tokens](#delegated-oauth-tokens).\n\n**Deprecated auth modes (do not use in new code):**\n- **API_KEY** — user API keys (from `x-api-key` header). Deprecated — use identity access tokens instead.\n- **SERVICE_TOKEN** — service tokens (Bearer tokens starting with `st.` prefix). Deprecated — use identity access tokens instead.\n\nIf you encounter `API_KEY` or `SERVICE_TOKEN` in existing code, do not replicate them in new routes or services. All new machine authentication should use `IDENTITY_ACCESS_TOKEN`.\n\nToken detection logic in `inject-identity.ts` checks `x-api-key` header first, then parses `Authorization: Bearer` and inspects JWT `authTokenType` field to determine mode.\n\n#### Delegated OAuth tokens\n\nTwo grants issue them: the authorization code flow (consented `scopes`) and the RFC 8693 token exchange\n(`delegation: \"full\"`, no scopes).\n\n**Opting a route in.** `verify-auth.ts` rejects `AuthMode.OAUTH` on any route that doesn't list it; most\nproduct `AuthMode.JWT` routes do. Two things have to hold before adding it. The route must not be an\nadministration route (below). And the handler must actually **check** a CASL ability, because that check\nis where the granted scopes get intersected in: building an ability and throwing it away\n(`getOrgPermission` used as a membership gate), or reading `hasRole(...)` off its return value, narrows\nnothing; `hasRole` is the easy one to miss because it reads like authorization. Rough proxy: nothing\npassing `requireOrg: false` accepts `AuthMode.OAUTH`.\n\n**Administration is read-only for a delegated token.** A delegated token can look at anything the\nauthorizing user can, but it changes nothing under **Administration**: no create, update, or delete, at\neither level. The frontend nav is the boundary, and it is worth opening (`OrgNav.tsx`,\n`OrgSubmenuView.tsx`, `SecretManagerNav.tsx`, `submenus.tsx`) when a new route's side is unclear. The\nsurfaces it covers:\n\n- **org access control** — members, groups, identities (including memberships, templates, and all\n  thirteen `identity-*-auth-router.ts`), org roles, invites, external group-to-role mapping\n- **usage & billing** — `ee/v1/license-router.ts`, `ee/v1/license-v2-router.ts`\n- **audit logs** — the org and project log reads plus audit log streams\n- **org settings** — org update and incident contacts, SSO and provisioning (SAML, OIDC, LDAP, SCIM,\n  GitHub org sync, email domains), networking (gateways, gateway pools, relays), encryption (external\n  KMS, KMIP, KMIP servers, HSM connectors), project templates, sub-orgs, workflow integrations\n  (Slack, Microsoft Teams), and the org-wide EnvKey/Vault migrations on the settings tab\n- **project access control** — memberships for users, groups and identities, project roles, additional\n  privileges, folder grants, project keys, service tokens, and the per-resource equivalents under PAM,\n  cert manager, code signing and PKI applications\n- **project settings** — project update and delete, environments, tags, approval policies, webhooks,\n  trusted IPs, secret validation rules, project KMS, blind index, audit log retention, E2EE upgrade,\n  and the secret scanning `configs` routes\n\nIn practice that means `GET` keeps `AuthMode.OAUTH` and `POST` / `PATCH` / `PUT` / `DELETE` lose it. The\nmethod is the test because `CODE_QUALITY.md` already requires `GET` to be safe, so it needs no per-route\njudgment. Two adjustments to that rule, both small enough to enumerate:\n\n- **A `POST` that only reads keeps it.** Five endpoints are `POST` solely because a lookup needs a request\n  body: `POST /v1/identities/search`, `POST /v2/identities/search`, `POST /v2/identities/search/count`,\n  and the two `memberships/details` routes. All five check a CASL read ability.\n- **A read that hands back credential material loses it**, because the point of closing administration is\n  that a third-party application should not end up holding the org's secrets. Five routes:\n  `GET /v1/sso/oidc/config` (`clientSecret`), `GET /v1/ldap/config` (`bindPass`),\n  `GET /v1/workspace/:projectId/kms/backup` (the project's KMS backup blob), and Slack\n  `GET /install` / `GET /reinstall`, which gate on `OrgPermissionActions.Create` and exist only to start\n  an install. Everything else reads through a sanitized schema — `sanitizedClientSecretSchema` returns a\n  prefix, `sanitizedExternalSchemaForGetById` drops provider credentials, `ScimTokensSchema` carries no\n  token. Check the response schema, not the route name, before opening a new administration read.\n\nThree things are first-party outright, because they sit outside an org and the nav boundary doesn't\nreach them:\n- account self-management (user, password, MFA, sessions, login, signup, notifications, announcements)\n- super-admin routes (`v1/admin-router.ts`), where `verifySuperAdmin` reads\n  `user.superAdmin` rather than an ability\n- `POST /v2/organizations`, and the org reads that return more than the token's org: `GET /v1/organization`,\n  `GET /v1/organization/accessible-with-sub-orgs`, `GET /v1/organization/:organizationId`. All pass\n  `requireOrg: false`, so nothing narrows them\n\nEverything else that used to be listed here individually — OAuth client CRUD, assume-privilege, deleting\nan org or sub-org, minting SCIM tokens, KMIP client certificates or gateway / relay / KMIP-server\nenrollment tokens, registering identity auth methods — is an administration write, and closed by the rule\nabove rather than by its own exception. Two of those reasons still generalize, so keep them in mind when a\n**product** route does the same thing:\n\n- **A route that leaves behind something able to authenticate later can't accept a delegated token.**\n  Revocation reaches only the sessions tagged with `getOauthClientSessionUserAgent`, so a credential or an\n  auth method bound to a key the caller holds survives deleting the application and rotating its secret,\n  and authenticates as a principal no OAuth scope intersects. Whether the route hands secret material back\n  is beside the point, so start any new route that writes a trust anchor (bound issuer, signing key, CA,\n  cloud principal) first-party.\n- **Nor can a route that reissues the caller's session.** `DELETE /v2/organizations/:organizationId`\n  returns a fresh pair from `loginService.generateUserTokens`, which takes no delegation parameter, so a\n  delegated caller would exit with a first-party session.\n\n**Delegation markers.** A token carries `scopes` or `delegation: \"full\"`, never both\n(`oauth-client-types.ts`). `permission-service` intersects the user's CASL rules with `scopes`\n(`applyOauthScopeToOrgRules` / `applyOauthScopeToProjectRules`). `inject-identity.ts` sets\n`RequestContextKey.OauthScopes` for every scope-narrowed token (even `[]`, which denies everything) and\nleaves it unset only for full delegation. Matching full delegation **positively** is what makes a dropped\nclaim fail closed instead of promoting the token.\n\n**The token endpoint's error contract is RFC 6749 §5.2, not the house envelope**, because generic OAuth\nlibraries branch on the `error` code. `OauthTokenError` (`oauth-token-error.ts`) carries the code and\nderives the status from it (400; 401 for `invalid_client`, 500 for `server_error`), and the shared error\nhandler renders `{ error, error_description }`. When extending it:\n- The handler's `try`/`catch` is the endpoint's only exit, and every `return` inside it is **awaited**: a\n  bare `return promise` would resolve outside the `catch` and escape in the house envelope.\n- `toOauthTokenError` defaults by **error class**, never message text: `UnauthorizedError` and\n  `ForbiddenRequestError` take the rejected-grant code for that grant (`invalid_grant` on the redirect and\n  refresh grants, `invalid_request` on token exchange, per RFC 8693 §2.2.2), `BadRequestError` takes\n  `invalid_request`, and anything else becomes `server_error`, whose response carries no detail and whose\n  original is logged. Both refusal classes are listed because the exchange rejects an unusable subject in\n  two vocabularies: `exchangeSubjectToken`'s own membership checks raise `UnauthorizedError`, while\n  `getOrgPermission` raises `ForbiddenRequestError` for the same kind of refusal. A new way to refuse a\n  subject belongs in one of those two classes, or it lands in `server_error` as an unexplained 500.\n- Throw `OauthTokenError` directly where that default is wrong: client authentication\n  (`invalid_client`), a grant the client doesn't hold (`unauthorized_client`), a redirect-URI or PKCE\n  mismatch (`invalid_grant`), the unsupported `scope` (`invalid_scope`) and `audience`/`resource`\n  (`invalid_target`) parameters, and org misconfiguration or IdP outages (`server_error`).\n- The route sets `attachValidation` so a schema failure reaches the handler; an unrecognized `grant_type`\n  earns `unsupported_grant_type`.\n- `toErrorDescription` sanitizes and bounds `error_description` at the render boundary, so no throw site\n  has to remember RFC 6749's printable-ASCII restriction.\n- Messages shared with a management route (the OIDC-config checks) take an error factory rather than\n  throwing, since the two callers owe the same explanation in different envelopes.\n\n**Token exchange trusts the org's OIDC SSO config, not per-client configuration**\n(`oauth-token-exchange-fns.ts`), so the issuers that can vouch for a user are the ones that can already\nlog them in. The one per-application field is `tokenExchangeAudience`, and it does the security work:\nwithout it, any token that issuer signed for anything in the estate is exchangeable. Enabling the grant,\nchanging the audience, and rotating the secret each need `OrgPermissionSsoActions.Edit` on top of the usual\n`OauthClients` check (`checkSsoConfigPermission`), rotation included, since its response is a working\ncredential for acting as any of the org's users.\n\n`getOrgPermission` is **not** a membership check: the org-scope query in `permission-dal.ts` never reads\n`Membership.isActive` or `Membership.status`. Every other user token came from a browser login, where\n`selectOrganization` rejects an inactive membership; the exchange has no login in front of it, so\n`exchangeSubjectToken` checks membership state itself. Any future non-interactive way of minting a user\ntoken needs the same check.\n\n**Withdrawing an exchange application's authority revokes the tokens it already issued.** Deleting the\nclient, rotating its secret, and any update that narrows the exchange's trust (dropping the grant,\nchanging `tokenExchangeAudience`, switching `tokenExchangeIdpSatisfiesMfa` off) all call\n`revokeSessionsByUserAgent`. `hasWithdrawnTokenExchangeTrust` and `hasClientAuthorityChanged` guard the\nsame fields, so a new field belongs in both. The test is whether it carries federation trust, not merely\nwhether it can be narrowed. `accessTokenTTL` is in neither: it only decides how long a new token lasts, so\nrevoking on a routine edit would surprise, and guarding it would fail in-flight exchanges. Widening\n(turning the MFA declaration on) revokes nothing. Rotation revokes for exchange clients only, where the\nsecret alone mints tokens; in the redirect flow it has to be paired with a code or refresh token, so\nblanket revocation would just sign everyone out. The session tag is per client, which is unambiguous\nbecause `assertValidOauthClientGrantConfig` rejects the exchange grant alongside `authorization_code`, so\na service needing both registers twice.\n\nThat sweep only reaps sessions that already exist, so **the exchange rechecks the client after creating\nits own session** (`findByIdForUpdate` + `hasClientAuthorityChanged`, re-running the sweep). The other\ngrants read an existing session rather than creating one, so they don't need it. The recheck has to be a\n**locking** read on the primary, and both halves of that are load-bearing: each withdrawal path writes the\nclient and sweeps sessions inside one transaction, so a plain read still sees the pre-withdrawal row until\nthat transaction commits, and would clear a token whose session the sweep has already scanned past. A\nreplica read has the same hole for the length of the replication lag. Keeping the write and the sweep\natomic is what makes the lock the fix here; splitting them so the write commits first would reopen a\nwindow where the sweep can fail on its own and leave the tokens live.\n\n**Everything the exchange fetches from the IdP is cached for 10 minutes; nothing read from our own\ndatabase is**, so an admin editing the SSO config takes effect on the next request while the provider\nnever becomes a synchronous dependency of the middleware's own request path. The cache invariants\n(rejections evicted, in-flight promise shared, expiry rebuilding the SSRF-pinned agent) are documented in\n`oauth-token-exchange-fns.ts`.\n\n### Resolving Users From Provisioning Identifiers\n\nEvery invite / removal path resolves a person by `users.username` (which always equals their email),\nnever by `users.email` and never by a join. An external provisioning system, though, often knows\npeople only by the identifier its IdP asserts, a UPN like `m249913@one.example.com` rather than the\nmailbox `robert@example.com`. SSO login already records that identifier as\n`user_aliases.externalId`, so the two are reconcilable without making `username` diverge from\n`email`.\n\n`resolveUsersBySsoExternalId` in `src/services/user-alias/user-alias-fns.ts` is the one place that\ndoes it, backed by `userAliasDAL.findBySsoExternalIds`. Callers run it **only after an exact\n`username` lookup has missed**, so an alias can never shadow a real account. It is wired into\n`$getUsers` (`membership-user-service.ts`, the single funnel for org, project, and cert-manager\ninvites), `project-membership-service.ts` (removal and membership read-back), and PAM's\n`addProductUserMembers`.\n\nFour invariants, each load-bearing:\n\n- **Scope by `orgId` *and* `aliasType`.** `user_aliases.orgId` is NULL for the global\n  google/github/gitlab aliases, so `whereIn(\"orgId\", ...)` excludes them outright and the\n  `ORG_SCOPED_USER_ALIAS_TYPES` filter is the second lock. Without both, an org admin could name a\n  user in another tenant.\n- **Match `externalId` exactly, never folded.** It is a case-sensitive identifier (OIDC Core defines\n  `sub` that way, as does SAML for nameID) and is stored verbatim, so folding case could collapse two\n  distinct IdP subjects onto one identifier. The known consequence: because both invite routes\n  lowercase their input (`sanitizeEmail`, and the `.refine` on `usernames`), an IdP that asserts\n  mixed-case identifiers cannot be provisioned against at all. Supporting those means relaxing that\n  input validation so the exact identifier survives to the query, not loosening the comparison.\n  `adoptProvisionedShadowUser` does not fold either. It derives its lookup key with `sanitizeEmail`\n  because it searches the `users.username` namespace, where lowercase is canonical, but it then\n  **refuses any identifier that is not already canonical** rather than adopting on the folded match.\n  It once did adopt, and that was a real hole: a subject differing only by case is a different\n  subject, so it could take over the placeholder provisioned for another one and inherit its grants.\n  Folding bought nothing anyway, since the alias written afterwards is verbatim and this exact-match\n  lookup could never find it again, stranding the grant where provisioning cannot manage it.\n- **Ambiguity is an error.** Nothing constrains `(externalId, aliasType)` to be unique for the\n  org-scoped types (only the social ones have a partial unique index), so an identifier can reach two\n  users. Picking one would be a guess about which human it names, and the cost of guessing wrong is\n  granting access to the wrong person. Several aliases on the *same* user are not a conflict.\n- **Dedupe resolved users by `id`.** An identifier now reaches a user by either their username or\n  their alias, so one request can name the same person twice. Undeduped, that violates\n  `membership_unique_user_org` and surfaces as a 500.\n\nNothing constrains a user to one alias per `(orgId, aliasType)`, and SSO login mints a new one\nwhenever the asserted subject differs from what SCIM last wrote, so **a SCIM read must match a\n`userName` against every one of a user's aliases, not the newest**. Matching only the newest made a\nprovisioned user vanish from `GET /Users?filter=userName eq \"...\"` the first time they logged in\nunder a different subject, and the IdP answered that empty lookup by provisioning them again. It was\nintermittent because `replaceScimUser` rewrites `externalId` on *all* of a user's aliases, so the\nnext PUT healed it until the next login. `$buildScimMembershipQuery` (`org-dal.ts`) therefore\nanswers every `userName` comparison with an `EXISTS` over the user's aliases, so the predicate is per\nuser rather than per alias row. That matters for negation: evaluated per row, `userName ne \"x\"` or\n`not (userName eq \"x\")` would keep a user through their other alias, and a member with no alias at\nall would fall out through a NULL comparison. The parser (`lib/knex/scim.ts`) lets an attribute\nresolve to a handler instead of a column for exactly this. Display is separate: the query still\njoins every alias and collapses the fan-out with `DISTINCT ON`, ranked so the alias row that\nsatisfies the filter wins and the newest is the fallback, which is how a lookup by an older alias\nechoes that alias back. The list query also carries a total order, because an IdP walking\n`startIndex`/`count` over an unordered result loses users the same way.\n\nA related case sits on the login side: provisioning can name someone before they have ever logged\nin, leaving a placeholder account keyed on the identifier instead of the mailbox.\n`adoptProvisionedShadowUser` (same file, wired into `oidcLogin`'s no-alias branch) adopts that row\nand rewrites it to the asserted mailbox rather than creating a second account. It refuses anything a\nhuman has claimed (accepted, email-verified, holding a password), anything already bound to an IdP\n(any alias, any org), ghosts, anything whose identifier is not already canonical (see above), and\nanything without a membership in the org doing the login.\n\nOne case is a refusal to *log in* rather than a refusal to adopt: a placeholder whose membership in\nthe login org is **inactive**. Declining is not neutral there, because the caller reads a `null` as\n\"no placeholder\" and creates a second account with a fresh active membership, handing a deactivated\nperson their org back (`selectOrganization` then accepts it). So the inactive check is resolved\nbefore every remaining decline — the alias check and the cross-tenant check both sit after it — and\nit throws the same `ForbiddenRequestError` an already-aliased deactivated member gets.\n\nIt also refuses a placeholder that holds an org membership **outside** the login org's own sub-org\nfamily, and that one is the security-critical check rather than a tidiness one. The username lookup\nthat finds the placeholder is global, so a second tenant that invited the same identifier shares the\nrow; adopting it would hand the login org's IdP subject that tenant's memberships, and\n`selectOrganization` accepts a membership in any status (promoting `Invited` to `Accepted` on\narrival), so nothing downstream stops the inherited access from being used. A project membership\nalways implies an org membership in the same org, so the org-scope check covers project access too.\n\nAdoption also recovers from a unique violation on `users.username`, because the caller's preceding\nread is not a lock. That recovery has to run inside a savepoint (`tx.transaction()`, which knex\ncompiles to `SAVEPOINT`/`ROLLBACK TO SAVEPOINT` on the same connection): Postgres aborts the whole\ntransaction on a constraint violation, so an unscoped retry would fail with `25P02`, taking the\ncaller's remaining alias and membership writes with it. SAML and LDAP have structurally identical\nbranches and are deliberately not wired up.\n\nBecause adoption rewrites an existing account's `username` and `email`, it emits an\n`OIDC_PROVISIONED_PLACEHOLDER_ADOPTED` audit event carrying the before and after, not just an\napplication log: if one of the refusal checks above ever regresses, the audit trail is what makes it\nfindable. That event must never fire on the unique-violation recovery path, where the returned user\nis whoever won the race rather than a rewritten placeholder. `adoptProvisionedShadowUser` draws that\nline by returning `adoptedFromUsername: null` for the yield, and the caller keys the audit log on\nit.\n\nThat trail is best-effort, not guaranteed. `audit-log-queue.ts` drops every entry at push time when\n`plan.auditLogsRetentionDays` is 0, which is the default for a self-hosted instance with no\naudit-log entitlement, so on those deployments only the `logger.info` line survives an adoption.\n(`null` is the License Server's \"No limit\" and keeps everything; see `audit-log-retention-fns.ts`.)\nDo not special-case this event past the retention gate; treat the application log as the floor and\nthe audit event as the addition for licensed instances.\n\n### Profile Sync From The IdP (SSO-enforced orgs)\n\nAn IdP keyed on a stable identifier lets someone change mailbox and display name without changing\nwho they are, so our copy of both goes stale: notification mail goes to an address that no longer\nexists, and every audit entry written from then on records it. `syncSsoUserProfile`\n(`src/services/user-alias/user-alias-fns.ts`) carries the asserted email and name onto the account,\nand is called from all three login services right after `ensureSsoAccountVerified`, outside the\ncaller's transaction, with its result flowing into the session.\n\nThe gate is `organization.authEnforced` **and** a verified alias, and both halves matter. Enforcement\nis the org saying the IdP is authoritative for identity, which is the same claim that already skips\nemail verification at signup. The verified alias is the proof that the IdP controls *this* account:\nan unverified alias asserting an unrecognized email is exactly what `isStaleSsoAlias` exists to\ncatch, and reading that as a rename would let a stale alias rewrite somebody else's account. One\nconsequence to know: a legacy unverified alias whose email changed before its next login is stale,\nso it gets the email-verification fallback (a code sent to the dead mailbox) rather than a sync.\n\nThree invariants:\n\n- **It never fails a login.** A rename that could not be applied leaves stale data, which is what we\n  already had; a throw locks the person out of an org that has no other way in. Every failure path\n  returns the unchanged user.\n- **It only renames an address the org owns, onto another address the org owns.** Both halves are\n  checked in the helper against the org's verified domains. The user row is global rather than\n  org-scoped, so without the first half an org could rename an account that merely carries one of\n  its aliases and rewrite the identity every other org of that user sees. Every login path\n  establishes both already (`verifyEmailDomainOwnership` is a positive check: the domain must be\n  verified *for this org*, and the alias branch runs it against the existing account's username),\n  so the helper's copy is there to keep the rename from outliving those checks. It is unreachable\n  today, and audits the skip anyway (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"domain-not-owned\"`) so a refactor that removes a caller's check leaves a trail rather\n  than a silently stale account.\n- **It never renames onto an occupied address.** `users.username` is globally unique and the row is\n  global rather than org-scoped, so the asserted address may already be a personal signup or an\n  unmerged duplicate. The conflict is recorded (`SSO_USER_EMAIL_SYNC_SKIPPED` with\n  `reason: \"address-taken\"`) and the email is left alone; the name still syncs. The preceding read\n  is not a lock, so a unique violation lands on the same path.\n\nSCIM is the other half of the same story and moves with it. `updateScimUser` / `replaceScimUser`\nrejected every email change outright, which left the data stale *and* put the provisioning job in a\npermanent error state (Entra quarantines after repeated failures). Both now accept the change under\nthe same `authEnforced` gate via `$resolveScimEmailChange`, and answer an occupied address with\n`409 uniqueness` rather than a constraint-shaped 500. Self-service email change\n(`user-service.ts`) is refused when the next login would overwrite it anyway, which\n`$getManagedEmailReason` narrows to the case that actually would: the account's own address sits on a\ndomain one of its SSO-enforced orgs has verified. Membership in an enforced org is not the test, since\nthe user row is global and an address outside that org's domains is one `syncSsoUserProfile` will not\ntouch. SCIM stays a blanket refusal, because the directory provisions the address either way.\n\n### Permission System (CASL)\n\nUses CASL (`@casl/ability`) with MongoDB-style rules. Permission logic lives in `src/ee/services/permission/`:\n- `permission-service.ts` — factory that builds CASL abilities from user/identity roles\n- `project-permission.ts` — defines project-level permission actions and subjects\n- `org-permission.ts` — defines org-level permission actions and subjects\n\n**Project permission actions** include standard CRUD plus specialized ones like `DescribeSecret` (see metadata without value), `ReadValue`, `GrantPrivileges`, `AssumePrivileges`, `Lease` (for dynamic secrets). See `ProjectPermissionActions`, `ProjectPermissionSecretActions`, `ProjectPermissionDynamicSecretActions`, and `ProjectPermissionIdentityActions` enums in `project-permission.ts`.\n\nBuilt-in roles: `Admin`, `Member`, `Viewer`, `NoAccess`. For PAM and Agent Vault `getPredefinedRoles` (`project-role-fns.ts`) returns only `Admin` and `Member`, because their permission dispatch resolves every other slug to the member set; the role factory delegates to that one function, so every role picker follows. Custom roles use unpacked CASL rules stored in the database. Rules can include conditions with operators `$IN`, `$EQ`, `$NEQ`, `$GLOB` (for pattern matching like `prod-*`). See `PermissionConditionSchema` in `permission-types.ts`.\n\n**Privilege boundaries mean different things on the two privilege systems.** `organizations.shouldUseNewPrivilegeSystem` (default `true`, backfilled `false` for orgs predating March 2025) picks which. `validatePrivilegeChangeOperation` / `assertRoleSetBoundary` (`permission-fns.ts`) are the shim: on the new system they collapse to `actorPermission.can(action, subject)`, on the legacy one they run `validatePermissionBoundary`, requiring the actor to out-rank every role the target holds. So a boundary at a call site that already gated on the same action is a no-op for new-system orgs and the only protection for legacy ones. Two consequences: \"the route checks the action\" is not a substitute for a boundary, and adding one is not a behavior change for most tenants. Prefer `getOrgPermissionByRoles` / `getProjectPermissionByRoles` + `assertRoleSetBoundary` over fetching the target's merged ability. It bounds each role separately, and it avoids `getProjectPermission`, which stamps whichever actor it was called for onto the request context the audit log reads.\n\n**Identity auth-method access goes through `assertIdentityAuthAccessAllowed`** (`src/services/identity/identity-auth-permission-fns.ts`), because repointing an identity's auth trust lets the caller authenticate as that identity. It's both the gate and the boundary: it checks the identity action itself on both privilege systems, and on legacy also makes the actor out-rank every grant the target holds. The boundary alone isn't enough, since it passes for any target you cover, and for an org identity it only sees org-scope grants, so a no-access org identity that's admin in projects would be open to anyone. Callers don't repeat the action check. The 13 auth services and the template update call it once per attach/update/revoke, outside the `identity.projectId` branch so both scopes are covered, and a new auth method should too. Only the fine-grained action gates these paths, at both scopes; the old coarse org checks (`edit`, `create`, `read`, `delete`) are gone. Credential issuance (`createTokenAuthToken`, `createUniversalAuthClientSecret`) goes through it under `create-token`, since minting a credential is the same escalation as repointing the trust, and so do the per-credential paths: `updateTokenAuthToken` under `create-token`, `getUniversalAuthClientSecrets` / `getUniversalAuthClientSecretById` under `get-token`, and `revokeUniversalAuthClientSecret` under `delete-token`. **`PROJECT_ACTION_BY_ORG_ACTION` must map each org action to its project equivalent**, since project identities get checked against the mapped one. `revokeTokenAuthToken` and `clearUniversalAuthLockouts` are still unbounded in both scopes (and the former gates on `edit` where its UA counterpart gates on `delete-token`); that predates the helper and is a deliberate gap, not an oversight to copy. The target's grants come from `permissionService.getActorGrantAbilities`, not from a membership lookup, and it returns one ability per grant rather than per role. Two things a role-slug resolver misses: a group-derived membership carries a NULL `actorIdentityId`, so `membershipIdentityDAL.getIdentityById` never sees it, and an additional privilege carries a raw permission blob with no slug at all, so no `*PermissionByRoles` path can express it. Either omission clears an actor that out-ranks the target's direct roles but not its effective access. `getActorGrantAbilities` reads the same `permissionDAL.getPermission` query the ability itself is built from, so the bounded set cannot drift from the effective one.\n\n**Auth template propagation is serialized with row locks on `identity_auth_templates`.** A template edit rewrites every linked identity's auth, and a link copies the template's values onto the identity row, so the two must not interleave: a link landing between the edit's permission loop and its writes would be rewritten by an actor never checked against it, and an edit landing between a link's validation and its insert would leave the identity holding values the template no longer has. `updateTemplate` and `deleteTemplate` take the row `FOR UPDATE` as the first statement of their transaction, and the edit re-reads usages through `tx` under that lock and returns a 409 if an identity appeared since the checked set was built; its propagation writes are also scoped to the checked ids so the privilege invariant does not rest on the lock. Every path that copies template values onto an identity (the three attaches, and the three identity updates when they load a template) calls `assertTemplateUnchangedForLink` (`ee/services/identity-auth-template/identity-auth-template-fns.ts`) as the first statement of its transaction: a `FOR SHARE` read plus an `updatedAt` comparison against the row it validated outside the transaction, 409 on mismatch. Lock order is template row first, identity row second; unlink and non-linking identity updates take no template lock and never wait on it, so no cycle is possible. Nothing slow may sit under either lock, which is why LDAP attach runs `testLDAPConfig` before its transaction opens. A new path that copies template values onto an identity must call the helper inside its transaction.\n\n**Deny an RBAC check by throwing through CASL, never with `ForbiddenRequestError`.** `audit-log-permission-denied.ts` records a permission-denied audit event for every CASL `ForbiddenError` and `PermissionBoundaryError` a request throws, and skips plain `ForbiddenRequestError` because most of those are auth-mode, plan and ownership refusals. A service that calls `permission.can()` and throws `ForbiddenRequestError` itself therefore produces no denial event, and nothing fails to tell you. When the check has a fallback (a second action, an approver, an application grant), test the fallbacks first and make `ForbiddenError.from(permission).throwUnlessCan(...)` the last check, so the denial is recorded with a real\n\n<!-- File: CLAUDE.md (Tokens: ~3780 | Category: claude-rule) -->\n# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Essential Commands\n\n- `make reviewable-api` / `make reviewable-ui` — lint:fix + type:check (run before PRs). Neither checks [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md); review backend changes against it yourself.\n- `cd backend && npm run migration:new` — create new DB migration\n- `cd backend && npm run generate:schema` — regenerate Zod types from DB after migration changes\n- `make test-api-unit` / `make test-api-e2e` — run the Node backend suites in the FIPS image CI uses, against a throwaway database. Narrow with `SPEC=<pattern>`. See `backend/CLAUDE.md` for why running `npm run test:e2e` directly can destroy your dev data.\n- `cd backend-go && make test` — run Go integration tests\n\nBoth backend and frontend use `@app/*` as path alias to `./src/*`.\n\n## Repository Structure\n\nInfisical is an open-source secret management platform. Monorepo layout:\n\n```\ninfisical/\n├── backend/               # Fastify 4 API server (see backend/CLAUDE.md)\n├── backend-go/            # Go API server — partial rewrite (see backend-go/CLAUDE.md)\n├── frontend/              # React 19 SPA (see frontend/CLAUDE.md)\n├── wasm/                  # Rust crates compiled to WASM for the frontend (see wasm/<crate>/CLAUDE.md)\n├── e2e/                   # External Playwright suite — gates prod deploys against gamma (see e2e/CLAUDE.md)\n├── tests/                 # Go blackbox suite — real containers, HTTP only (see tests/CLAUDE.md)\n├── docs/                  # Documentation site (Mintlify-based)\n├── build-versions.env            # Versions pinned across several Dockerfiles (see Dependency Policy)\n├── docker-compose.dev.yml        # Local dev (PostgreSQL, Redis, backend, frontend, Nginx)\n├── docker-compose.prod.yml       # Production deployment stack\n├── docker-compose.bdd.yml        # BDD testing environment\n├── docker-compose.e2e-dbs.yml    # E2E test databases (Oracle, SAP, Snowflake, etc.)\n├── Dockerfile.standalone-infisical       # Standalone image (frontend + backend)\n├── Dockerfile.fips.standalone-infisical  # FIPS-compliant standalone image\n├── .github/               # CI workflows, PR template\n└── CLAUDE.md               # This file\n```\n\n- **`backend/`** — Fastify 4 API server, TypeScript, PostgreSQL via Knex, BullMQ queues. See [`backend/CLAUDE.md`](backend/CLAUDE.md) for architecture, patterns, and commands.\n- **`backend-go/`** — Go API server (partial rewrite), chi + chita framework, raw pgx queries, same PostgreSQL database. See [`backend-go/CLAUDE.md`](backend-go/CLAUDE.md) for architecture, patterns, and commands.\n- **`frontend/`** — React 19 SPA, Vite 8, TanStack Router + React Query, Tailwind CSS v4. See [`frontend/CLAUDE.md`](frontend/CLAUDE.md) for architecture, patterns, and commands.\n- **`wasm/`** — Rust crates that compile to WASM for the frontend. Generated bindings are committed under `frontend/src/lib/<crate>/` so the frontend builds without a Rust toolchain. Each crate has its own `CLAUDE.md` with the rebuild command (e.g. [`wasm/ironrdp-decoder/CLAUDE.md`](wasm/ironrdp-decoder/CLAUDE.md)) — run it after any change to that crate's `src/` or `Cargo.toml` so source and bindings stay in sync.\n- **`docs/`** — Product documentation site. Has its own Dockerfile for building. Reference docs for up-to-date feature descriptions and API usage.\n- **`e2e/`** — Playwright suite that runs against a deployed environment (gamma) between deploy and prod promotion. Distinct from `backend/e2e-test/` (in-process Vitest). Failure blocks every prod-deploy job. Covers SCIM + SAML flows (SP-initiated, IdP-initiated, deactivation, response rejection) against a mock IdP we control — see [`e2e/CLAUDE.md`](e2e/CLAUDE.md) for the harness and the one-time gamma bootstrap.\n\n- **`tests/`** — Go blackbox suite. Boots a real Infisical in Docker and talks to it over HTTP only, so the same tests run against the Node and Go servers unchanged. Distinct from `backend/e2e-test/` (in-process Vitest, compiled against the Node source) and from `e2e/` (Playwright against a deployed gamma). `make test-suites` from `tests/` runs everything; see [`tests/CLAUDE.md`](tests/CLAUDE.md) before writing one.\n\nEnterprise features live in `backend/src/ee/` (services and routes), registered before community routes so they can override/extend them.\n\n### Self-Hosted Deployment\n\nInfisical supports self-hosted deployment via Docker. Key considerations:\n- **`Dockerfile.standalone-infisical`** — single-container image with both frontend and backend; used for simple deployments.\n- **`Dockerfile.fips.standalone-infisical`** — FIPS 140-2 compliant variant for regulated environments. Be strict about not introducing dependencies that break FIPS compliance.\n- **`docker-compose.prod.yml`** — production compose with backend, PostgreSQL, and Redis.\n- New backend dependencies should be evaluated carefully — they affect container size, FIPS compliance, and the encryption boundary. Check `docs/` for self-hosted deployment documentation when in doubt.\n\n### Dependency Policy\n\n`build-versions.env` holds versions that several Dockerfiles install, currently the bundled\nInfisical CLI. Each Dockerfile keeps a matching `ARG` default so external builders (Northflank,\nRender, a plain `docker build`) work without passing anything, and CI and compose override it from\nthat file. `check-dockerfile-pins.yml` fails a PR when the defaults drift from it, so change the\nfile and the `ARG` defaults together.\n\nBoth `backend/` and `frontend/` enforce a minimum release age of 7 days for npm packages (configured via `.npmrc` in each directory). This means `npm install` will only resolve package versions published at least 7 days ago, as a supply-chain security measure.\n\nThe backend installs with the npm bundled in Node 26 (11.x), which skips dependency install scripts unless `allowScripts` in `backend/package.json` approves them. The install still succeeds when a script is skipped, so a native addon that was not approved fails only when it is first `require`d. When adding or bumping a dependency with an install script, run `npm install-scripts ls` and approve or deny what it lists (`npm install-scripts approve <pkg>`).\n\n## Cross-Cutting Patterns\n\n### Backend Code Quality\n\n**Read [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md) for every backend change, and check the change against it before calling the work done.** This applies to all work under `backend/`: new features, refactors, bug fixes, and reviews alike.\n\nIt is a floor, not an exhaustive standard: user-understandable error messages and no pointless 500s, explicit validation on every API input, correct pagination when calling third-party APIs, avoiding deadlock conditions on a small connection pool (thread `tx`, keep transactions short), REST-aligned API interfaces (flag deviations for the author to confirm rather than implementing them silently), and audit log events an admin can actually read.\n\nThat list describes what the guide currently covers; it is **not** a test for whether the guide applies. Do not skip it because a change does not look like one of those topics. Read it, then decide which items are relevant.\n\n### Code Comments\n\n**Default to no comments.** One earns its place only by explaining *why*: a non-obvious constraint, a workaround, an ordering dependency, or logic that looks wrong until you know the reason.\n\nNever write: narration restating the next line; section headers inside a function (`// --- validation ---`); change history (`// Added retry logic`, `// NEW`); references to plans, tickets, PRs, or reviewers; docstrings restating the signature; commented-out code.\n\nBefore finishing, delete any comment you added that only says what the code says.\n\n### Product Analytics\n\nRead [`ANALYTICS.md`](ANALYTICS.md) before adding or changing product analytics. Every event has one owning producer: the frontend records UI exposure and intent, the application backend records accepted commands it can establish, and the system of record records durable outcomes. Define stable dimensions, cardinality, firing conditions, and dashboard consumers before implementation.\n\n### Design System & Voice\n\nThe v3 visual system (colors, typography, components, layout) and product voice/content tone are documented in [`DESIGN.md`](DESIGN.md). Read it before producing new UI or user-visible copy.\n\n### Documentation\n\n**Use the `docs-style` skill for any work under `docs/`** (`.agents/skills/docs-style/`). It carries the procedure for the [Documentation Style Guide](docs/STYLE_GUIDE.md): what belongs on a page, and the sentence-level review pass Vale cannot check.\n\nIf the user wrote or edited the docs prose themselves, don't just accept it. Tell them the `docs-style` skill can run the review pass over their changes, and offer to run it.\n\nThe style guide covers writing for users (not implementers), Mintlify component usage, cross-referencing, page structure, the sentence-level writing rules in section 5, and the bolding and UI conventions in section 11.\n\nRun `make lint-docs-branch` after any change under `docs/`. It runs [Vale](https://vale.sh) over the docs and enforces the mechanical half of the style guide: sentence case in headings and frontmatter titles, product and vendor spellings, spelling against a curated vocabulary, `$` prompts in code blocks, and em dash density. See [docs/CONTRIBUTING.MD](docs/CONTRIBUTING.MD) for how to extend the vocabulary or suppress a rule. It lints only the `.mdx` files the branch touched; `make lint-docs` checks every page. The `Check docs style` GitHub workflow runs that same script, so local and CI agree by construction. Every rule except `Infisical.EmDashes` reports at error level, including `Infisical.UIActions` (click/tap where the verb should be select) and `Infisical.Contractions`, so the run fails if either one reports a finding. `Infisical.EmDashes` reports at warning level and never changes the exit code -- read the printed output, not just the status. Note that Vale cannot see prose indented four or more spaces inside components -- roughly half of this repo -- and reports nothing about what it skipped, so a clean run does not mean a nested page was checked.\n\n### Auth & Permissions\n\nAuth modes (JWT, IDENTITY_ACCESS_TOKEN, SCIM_TOKEN) are extracted in `backend/src/server/plugins/auth/`. Authorization uses CASL (`@casl/ability`) with project-level and org-level permission checks — see `backend/CLAUDE.md` for backend details and `frontend/CLAUDE.md` for frontend permission hooks/HOCs. Note: `API_KEY` and `SERVICE_TOKEN` auth modes are deprecated — do not use them in new code.\n\n### Org-Scoped Products\n\nPAM and Agent Vault are products over one implicit project per organization, not projects a user creates. Their URLs are `/organizations/$orgId/<product>/…` with no `$projectId`, the frontend resolves the project from the org (`useImplicitProjectId`), the backend bootstraps it lazily and blocks generic create and delete, their roles are admin or member only, and each has its own metered identities dimension (`pam_identities`, `agent_vault_identities`). When you add a `ProjectType.PAM` arm anywhere, add the Agent Vault arm beside it. See `backend/src/ee/services/pam/CLAUDE.md` and `backend/src/ee/services/agent-vault/CLAUDE.md`.\n\n### Single-Project Products\n\nSecret Scanning has one active project per organization (the newest). Like Certificate Manager, its URLs are `/organizations/$orgId/secret-scanning/…` with no `$projectId`; legacy orgs can still hold several projects, so the frontend pins the one being viewed (see `frontend/CLAUDE.md`). New orgs and sub-orgs get it at creation (`bootstrapSecretScanningProject`), older orgs lazily from `GET /api/v2/secret-scanning/project`, and `projectId` is optional on its API (`inject-secret-scanning-project-id`). Do not add a project picker or a way to create more Secret Scanning projects.\n\n### Service Factory + Manual DI (Backend)\n\nNo IoC container in either backend. Every service is a factory function with explicit dependencies.\n- **Node.js**: Wired in `backend/src/server/routes/index.ts` — see `backend/CLAUDE.md`.\n- **Go**: Wired in `backend-go/internal/server/api/api.go` via `NewRegistry()` — see `backend-go/CLAUDE.md`.\n\n### Interface Pattern (Go)\n\nBoth handlers and services define narrow interfaces for their dependencies (consumer-defined interfaces). Only expose methods or fields that are needed — keep everything else private. This enables testability and loose coupling.\n\n### Alerting\n\nAll user-facing \"notify me when X happens\" features share one module: `backend/src/services/alert/`. It owns the alert CRUD, the channel stack (email, Slack, webhook, PagerDuty), recipients, dedup, history, and dispatch. To alert on a new resource, register an `IResourceAlertProvider` on the shared registry — do not stand up a per-domain alert service, channel table, or notification cron. See `backend/CLAUDE.md` for the provider contract and invariants.\n\n**If you touch a code path that deletes or detaches an alertable resource, it must reap that resource's alerts.** `alerts.resourceId` has no foreign key, so nothing cascades and the alert is left dangling. Use `alertService.deleteAlertsForDeletedResource` when the row is gone (unscoped, reaps across every org) and `deleteAlertsForResource` when the resource only left a scope. See the alerting invariants in `backend/CLAUDE.md`.\n\n### Approvals\n\nAll \"someone has to approve this before it happens\" features share one module:\n`backend/src/services/approval-policy/`, which owns policies, requests, steps, grants, break-glass, and\nthe generic `/v1/<policy-type>/approval-policies` endpoints. Current types: PAM access, certificate\nrequests, code signing.\n\nA policy type plugs in through **one** extension point, a `TApprovalResource` (see its doc comment),\nconstructed with its own dependencies and registered in the `approvalResources` map in\n`server/routes/index.ts`; that also gives it the generic `/v1/approval-policies/<type>/...` endpoints,\nwhich every type now uses for policy CRUD, requests, approve, reject and grant revocation. **Do not stand up a per-product approval service, request table, or review\nendpoint**: `access-approval-*` and `secret-approval-*` predate this module and are what we are\nconverging away from. A product-facing service on top of it (PAM's `pam-access-request-service.ts`) is\nfor product vocabulary only: resolving the resource a request names, notifications, and read models.\nAny rule about what is *allowed* belongs in the resource, or the product's own API and the shared API\nstop agreeing.\n\n### API Layer (Frontend)\n\nReact Query + Axios with query key factories per domain. Each API domain in `frontend/src/hooks/api/` has `queries.tsx`, `mutations.tsx`, and `types.tsx` — see `frontend/CLAUDE.md` for conventions.\n\n## Keeping CLAUDE.md Up to Date\n\nWhen making significant changes to the codebase (new services, architectural shifts, new patterns, major refactors), update the relevant CLAUDE.md file(s) with high-level findings. This includes this root file for cross-cutting concerns, `backend/CLAUDE.md` for Node.js backend changes, `backend-go/CLAUDE.md` for Go backend changes, and `frontend/CLAUDE.md` for frontend changes. The goal is to keep these files accurate as living documentation so future sessions start with correct context.\n\n## Wiring a New Full-Stack Feature\n\n1. **Backend**: Create service module, migration, wire DI, add routes — see checklist in `backend/CLAUDE.md`\n2. **Frontend**: Add API hooks in `src/hooks/api/<domain>/`, create page/view, wire route — see `frontend/CLAUDE.md` for routing and component patterns\n3. Check the backend work against [`backend/CODE_QUALITY.md`](backend/CODE_QUALITY.md)\n4. Run `make reviewable-api` and `make reviewable-ui` before submitting\n\n## Helpful files\n\nClaude Code reads CLAUDE.md, not AGENTS.md, so the shared agent instructions are imported here rather than linked:\n\n@AGENTS.md\n\n<!-- File: e2e/CLAUDE.md (Tokens: ~3947 | Category: claude-rule) -->\n# CLAUDE.md\n\nGuidance for Claude Code sessions working in this directory. Covers both the operator-facing view (env vars, bootstrap, how to run) and the agent-facing context (architecture, gotchas, file layout).\n\n## What this directory is\n\nExternal Playwright suite targeting a deployed Infisical environment (gamma). Runs in CI between `gamma-deployment` and any prod-deploy job — defined as `gamma-playwright-e2e` in the `infrastructure` repo's `.github/workflows/infisical-deployment.yml`. A red run blocks all 11 prod/dedicated deploy jobs via `needs:`.\n\n**Not** the same as `backend/e2e-test/`, which is in-process Vitest with a bootstrapped server. That one tests the backend in isolation; this one tests the deployed product as a black box.\n\n## Scope discipline\n\nThis suite exists for **smoke-level** verification — \"does the surface still work end-to-end against real infrastructure.\" If a test is best expressed as \"given these inputs to a function, assert these outputs,\" it belongs in a unit or integration test, not here. The CI cost per test (minutes, not seconds) makes narrow coverage expensive.\n\nConcrete rule: a test here should be *useful even if every integration test passed*. It's catching things integration tests can't — config drift, deploy regressions, end-to-end auth flows that depend on real IdPs.\n\n## Architecture\n\n- **Playwright** — TypeScript, Chromium project only. Single worker, `fullyParallel: false`. The single-worker constraint is load-bearing — see \"Serial execution\" below.\n- **Two-token CI surface.** Only the two real secrets (`E2E_SCIM_TOKEN`, `E2E_IDP_ADMIN_TOKEN`) come from env vars — validated fail-fast in [`helpers/env.ts`](helpers/env.ts). Deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config row id) are constants in [`helpers/constants.ts`](helpers/constants.ts). The deliberate tradeoff: re-seeding gamma's e2e org rotates the `saml_configs.id`, which then has to be updated in code instead of in a GH Actions secret.\n- **Twingate-gated.** Gamma sits behind Twingate; the CI workflow installs the Twingate connector before tests run. Locally, you need to be on Twingate too. The Playwright Chromium inherits routes from the host network connector.\n- **Mock IdP we control.** SAML SSO points at `preview-orchestrator.infisical.workers.dev/saml-idp/*` — Hono routes on the existing `preview-environments` Worker, using `samlify` for signing under `nodejs_compat`. No SaaS account, no vendor DOM. The test calls `POST /saml-idp/identity` with a per-run NameID, sets the returned session id as a cookie on the Worker domain, then navigates to gamma's SAML start URL; the browser carries the cookie through gamma's 302 to the IdP, which mints a signed assertion for that identity. Cert is committed at [`fixtures/idp-cert.pem`](fixtures/idp-cert.pem); private key is in CF Secrets only (`SAML_IDP_PRIVATE_KEY`). Worker code lives in the `preview-environments` repo, not here — that side of the system is owned separately.\n- **Two SAML entry shapes, one spec file.** `tests/scim-saml-auth.spec.ts` covers both variants under one `describe`. SP-initiated navigates to gamma's `/api/v1/sso/redirect/saml2/organizations/:orgSlug` first; gamma 302s the browser to the Worker's `/saml-idp/sso` with an `AuthnRequest`. IdP-initiated starts at the Worker's `/saml-idp/initiate`, which mints an *unsolicited* signed `SAMLResponse` (no `InResponseTo` per SAML 2.0 §3.4.1.5) and auto-POSTs it directly to gamma's ACS at `/api/v1/sso/saml2/:samlConfigId`. The IdP-initiated test passes the gamma ACS URL through `POST /identity` (validated against the gamma SP origin allowlist), reachable via `gammaSamlAcsUrl()` in [`helpers/idp-client.ts`](helpers/idp-client.ts).\n\n## Load-bearing identity coupling\n\nThis is the one detail that, if you get it wrong, makes the test silently fail in confusing ways:\n\n```\nSCIM userName  ==  SAML NameID emitted by the IdP  ==  UserAlias.externalId\n```\n\nThe chain works because:\n- SCIM provisioning creates `UserAlias.externalId = req.body.userName` (backend `scim-router.ts:266` → `scim-service.ts:499`).\n- Org auth method is SAML, so the alias type is SAML (`scim-service.ts:428`).\n- SAML login looks up by `userAliasDAL.findOne({ externalId: profile.nameID, orgId, aliasType: SAML })` (`saml-config-service.ts:535`).\n\nWith the in-house mock IdP, the test now **owns this value end-to-end**: it generates a per-run `EXTERNAL_ID` (`e2e-${runId}@infisical-e2e.test`) and passes the same string as the SCIM `userName`, the IdP `email` (which samlify uses as the SAML `NameID`), and implicitly as `UserAlias.externalId`. There's no SaaS-side configuration to drift. If `tests/scim-saml-auth.spec.ts` and `helpers/idp-client.ts` agree on the value, the chain holds.\n\nThe current test still ends at `/signup/sso?token=…` rather than `/login/select-organization` — gamma routes any `userAlias.isEmailVerified = false` user through `SIGNUP_REQUIRED` (`auth-login-service.ts:322`), and SCIM hardcodes that field to `false` (`scim-service.ts:506`). The signup token JWT contains the verified identity fields. Patching the SCIM service to mark aliases verified at provisioning time would flip the redirect back to the original `/login/select-organization`; that's a separate backend change.\n\n## Serial execution is intentional\n\nTests run with a single worker. The old reason (single static IdP identity) is gone — the mock IdP can mint an assertion for any NameID the test asks for, so per-run unique externalIds already work.\n\nWhat still binds us to single-worker is the **shared gamma org**: there's one `saml_configs` row, one set of `email_domains` rows, one `orgAuthMethod`. Concurrent tests writing to the same org's `user_aliases`/`memberships` rows risk subtle interleavings (e.g. the same email landing on a fallback `userDAL.findOne({ username })` from two flows at once). Unblocking parallelism would mean either per-test gamma orgs (heavier seed script) or accepting that risk for tests that don't touch shared org state.\n\nIf you do enable parallel workers, also ensure the IdP `/saml-idp/identity` flow's cookie scoping survives — the Worker sets `path=/saml-idp`, so multiple concurrent tests can each set their own session cookie value on the Worker origin without collision (Playwright contexts are already isolated per test).\n\n## File layout\n\n| Path | Purpose | Touch when... |\n|---|---|---|\n| `playwright.config.ts` | Runner config — baseURL, retries, reporter | Adding a project (e.g. Firefox), changing retry policy |\n| `helpers/env.ts` | Fail-fast env loader for the two bearer-token secrets | Adding a new *secret* (also update `.env.example` and the CI workflow) — non-secret config goes in `constants.ts` instead |\n| `helpers/constants.ts` | Hardcoded deploy-level identifiers (gamma URL, org slug, IdP URL, SAML config id) | Gamma's e2e org is re-bootstrapped and `saml_configs.id` rotates — update `SAML_CONFIG_ID`; the seed script logs the new value on the `[seed] E2E_SAML_CONFIG_ID = <uuid>` line |\n| `helpers/scim.ts` | SCIM CRUD wrappers, reusable across tests | Any new SCIM operation a test needs (e.g. Groups) |\n| `helpers/idp-client.ts` | Talks to the mock IdP — stashes a NameID (+ optional ACS URL for IdP-initiated), sets the Worker-domain session cookie, and starts either SP- or IdP-initiated flows | Adding a new SAML-flavored flow, or if the Worker IdP's API/cookie scheme changes |\n| `tests/*.spec.ts` | One test per flow | Adding a new flow (see below) |\n| `fixtures/idp-cert.pem` | Committed public cert for the mock IdP; gamma's SAML config row holds this same cert (KMS-encrypted) | Rotating the IdP keypair (see [`fixtures/README.md`](fixtures/README.md)) |\n| `tsconfig.json` | TS config — no `baseUrl`/`paths` on purpose, relative imports only | Avoid unless you have a real reason |\n\n## Adding a new test\n\n1. **One *flow* per file**, not one test per file. A flow is a distinct end-to-end path being verified (e.g. \"SCIM-provisioned user logs in via SAML\", \"SAML response rejection\", \"SCIM deactivation revokes SAML login\"). Variants of the *same* flow that differ only in entry shape or input (SP-initiated vs IdP-initiated SAML; each strictness gate within a rejection suite) belong in the same file under one `test.describe`, as multiple `test()` blocks. Distinct flows get their own file.\n2. Name the file after the flow (`scim-saml-auth.spec.ts`, `saml-rejection.spec.ts`, `scim-deactivation.spec.ts`). Per-variant naming goes inside the file as test titles.\n3. Import from `../helpers/constants`, `../helpers/env`, `../helpers/scim`, `../helpers/idp-client`. Add a new helper module only if a fundamentally new flow needs shared setup (e.g. an OIDC helper if/when that's covered).\n4. **Per-variant state goes in `beforeEach` / `afterEach`**, not `beforeAll` / `afterAll`. Each variant test should own its own SCIM user (fresh `externalId`) so a mid-test crash on one variant doesn't poison the next. See `saml-rejection.spec.ts` for the pattern. Use `beforeAll`/`afterAll` only when there's genuinely shared, idempotent state across the whole file.\n5. **Always clean up.** `beforeEach` defensively deletes any state the test creates; `afterEach` deletes what the test produced. Tests crash mid-flow sometimes; the e2e org accumulates rows otherwise. Note: the SAML signup branch also creates a fresh user row on each run — currently leaks; revisit when SCIM's `isEmailVerified` default is patched.\n6. **One assertion-block per test should be enough** to claim the variant works. Factor a shared `expect…()` helper inside the file when multiple tests assert the same outcome shape (e.g. `expectSignupSsoRedirectFor(page, email)` in `scim-saml-auth.spec.ts`) — that's preferable to copying ~10 lines of expect calls across tests. Don't expand a smoke test into a unit-test-style assertion sprawl — push fine-grained detail into integration tests.\n7. **Don't share helpers across spec files.** Helpers live in `helpers/*` or as file-local consts/functions at the top of the spec. Cross-spec imports create CI debugging cliffs — avoid.\n8. Run locally first (`npm test`) with `.env` populated. CI will replicate via GH Actions secrets.\n\n## What you can break by accident\n\nThese external dependencies have to stay in sync — if you change the backend or the Worker, the test breaks silently. Worth grepping for after any change in these areas:\n\n- **SCIM endpoint URL** (`backend/src/ee/routes/v1/scim-router.ts`, mounted at `/api/v1/scim`). `helpers/scim.ts` hardcodes `/api/v1/scim/Users` and `/api/v1/scim/Users/:id`. If the router prefix moves, update here.\n- **SAML start/ACS URLs** (`backend/src/ee/routes/v1/saml-router.ts`). `helpers/idp-client.ts` hardcodes `/api/v1/sso/redirect/saml2/organizations/:orgSlug` (SP-initiated) and `/api/v1/sso/saml2/:samlConfigId` (IdP-initiated ACS, built from `SAML_CONFIG_ID` in `helpers/constants.ts`). If either path moves, both consumer sites need to follow.\n- **Post-login redirect** (`saml-config-service.ts` → `auth-login-service.ts:322`). `tests/scim-saml-auth.spec.ts` asserts `/signup/sso?token=…`; if SCIM gets patched to mark aliases verified, the redirect flips to `/login/select-organization` and the assertion needs to follow.\n- **NameID-to-externalId mapping** (`saml-config-service.ts:535`). Currently `profile.nameID`. If the matching attribute changes, the identity-coupling section above becomes stale.\n- **`scimEnabled` / `orgAuthMethod` semantics** (`scim-service.ts:413, 419`). If new gating gets added (e.g. additional flags), the seed script in `backend/scripts/seed-gamma-fixtures.ts` needs to set them.\n- **Verified email domain check** (`email-domain-fns.ts:11`). SCIM POST and SAML login both require the email domain to be on the org's `email_domains` allowlist with `status='verified'`. The seed script writes one for `infisical-e2e.test`; if the test ever uses a different domain, add a row.\n- **Mock IdP cert ↔ gamma SAML config cert** must match. Seed script reads the committed cert from `e2e/fixtures/idp-cert.pem` and KMS-encrypts it into the `saml_configs` row; the Worker signs with the matching private key from `SAML_IDP_PRIVATE_KEY` (CF Secret). Rotating one requires rotating the other — see `fixtures/README.md`.\n- **passport-saml strictness** (`saml-router.ts:51-117`). Under `authProvider = keycloak-saml` (what the seed script writes), no overrides apply — passport-saml requires both Response and Assertion signed, attribute `email`/`firstName`/`lastName` present, and audience matching `appCfg.SITE_URL`. `validateInResponseTo` defaults to `\"never\"` in `@node-saml/node-saml` 5.x, which is why the IdP-initiated path works at all; flipping it to `\"ifPresent\"` or `\"always\"` would break IdP-initiated first. The Worker's unsolicited template omits `InResponseTo` entirely (per SAML 2.0 §3.4.1.5) so the test stays valid if that default is ever tightened. Changing the seed's `authProvider` value would change the strictness profile.\n\n## Common pitfalls\n\n- **Diagnostics about `@playwright/test`** before `npm install` runs — expected. Resolve by `cd e2e && npm install`.\n- **The seed script's bootstrap is one-pass now.** Direct DB writes don't trigger `updateSamlCfg`'s `scimEnabled=false` side effect, so a single run leaves a usable state. (The old SSOJet-era two-pass requirement is gone.) Re-run is still safe; it's idempotent.\n- **CF Access bypass for `/saml-idp/*`** must be in place on the `preview-orchestrator` Worker, otherwise every request to the mock IdP 302s to the CF Access login page. The bypass is safe because the IdP routes have their own auth (`Authorization: Bearer SAML_IDP_ADMIN_TOKEN` on `/identity`, cookie + KV lookup on `/sso` and `/initiate`, intentionally public on `/metadata.xml`).\n- **Worker changes ship via `wrangler deploy`**, not the gamma deploy. After editing Worker code (`/saml-idp/*` routes, IdP templates), deploy from the `preview-environments` repo before running the test or it'll hit the previous version. `tsc` passing here doesn't prove the Worker is up to date.\n- **SCIM POST + SAML signup leak a user row per test run** because the SAML signup branch creates a fresh `users` row even though SCIM already created one. SCIM `DELETE /Users/:id` removes the membership but not the orphan user. Tractable but accumulates; revisit if it becomes noisy.\n- **Don't add a `.npmrc` here for the 7-day minimum-release-age rule** — `e2e/` is a test harness, not a runtime artifact. The supply-chain concern that motivates the rule in `backend/`/`frontend/` doesn't apply.\n- **CI uses `npm ci`** against the committed `package-lock.json` for determinism. If you change deps, regenerate the lockfile (`npm install`) and commit it in the same change, otherwise `npm ci` fails the prod gate.\n- **Don't widen what the seed script creates** unless you've thought through the safety. It already refuses prod (host/dbname regex + `INFISICAL_ENV=prod` + interactive confirmation). Adding more powerful operations means re-evaluating the guard surface.\n\n## Where context lives\n\n- CI workflow: `infrastructure` repo, `.github/workflows/infisical-deployment.yml` (the `gamma-playwright-e2e` job)\n- Seed script: [`../backend/scripts/seed-gamma-fixtures.ts`](../backend/scripts/seed-gamma-fixtures.ts)\n- Backend SCIM service: `../backend/src/ee/services/scim/`\n- Backend SAML service: `../backend/src/ee/services/saml-config/`\n- Mock IdP Worker code: `preview-environments` repo, `src/saml-idp/` (separate repo — owned independently)\n- IdP keypair provenance + rotation: [`fixtures/README.md`](fixtures/README.md)\n- Root architectural notes: [`../CLAUDE.md`](../CLAUDE.md), [`../backend/CLAUDE.md`](../backend/CLAUDE.md)\n\n<!-- File: frontend/CLAUDE.md (Tokens: ~3237 | Category: claude-rule) -->\n# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\nThis is the **frontend** package of the Infisical monorepo — a React 19 SPA built with Vite 8, TanStack Router, React Query, and Tailwind CSS v4.\n\n## Essential Commands\n\nAll commands run from the `frontend/` directory:\n\n- `npm run dev` — start dev server on port 3000\n- `npm run build` — TypeScript check + Vite production build\n- `npm run lint:fix` — ESLint autofix (double quotes, simple-import-sort with 7 groups)\n- `npm run type:check` — TypeScript type check only (`tsconfig.app.json`)\n- `make reviewable-ui` (from repo root) — runs `lint:fix` + `type:check` (run before PRs)\n- `npm run storybook` — Storybook on port 6006\n\nPath alias: `@app/*` maps to `./src/*`.\n\n## Architecture Overview\n\n### Routing (TanStack Router v1)\n\nVirtual file routes defined in `src/routes.ts` using a functional DSL (`route()`, `index()`, `layout()`, `middleware()`). The generated route tree is in `src/routeTree.gen.ts` (do not edit manually — regenerated on dev server start or when `src/routes.ts` changes).\n\nEach page directory has a `route.tsx` that defines its route:\n```tsx\ncreateFileRoute(path)({ component, validateSearch: zodValidator(schema), beforeLoad })\n```\n\nRoute middleware chain: root → `authenticate` → `inject-org-details` → org-layout → product-specific layouts (secret-manager-layout, cert-manager-layout, kms-layout, etc.).\n\nMiddleware pages in `src/pages/middlewares/`: `authenticate.tsx` (auth guard + redirect), `inject-org-details.tsx` (org context), `restrict-login-signup.tsx` (prevents auth pages when logged in).\n\n### Pages / Views / Components Hierarchy\n\n- **`src/pages/`** — Route-level components organized by product feature (secret-manager, cert-manager, kms, pam, secret-scanning, organization, project, admin, auth). Each has `route.tsx` + page component + local `components/`.\n- **`src/views/`** — Reusable page-level UI composed into multiple pages. Pages import views with configuration props.\n- **`src/components/v3/`** — Latest shared UI component library (preferred). Contains `generic/` (Accordion, Alert, Button, Dialog, Select, Table, etc.) and `platform/` (domain-specific components such as `PageHeader`, scope icons, `ProjectPermissionSubjects`, and `SecretManagerResources`). **Always use v3 components for new code.** Only use v2 components when a v3 equivalent does not exist. Policy group tiles look up `getProjectPermissionSubjectPresentation`. Secret Manager overview resources (name/slug/icon/color) come from `SecretManagerResources`, which points at that subject catalog.\n- **`src/components/v2/`** — Legacy shared UI components built on Radix UI primitives + Tailwind. Uses `cva` (class-variance-authority) for variants and `tailwind-merge` for class conflict resolution. Being superseded by v3 — do not use for new features if a v3 alternative exists.\n\n### API Layer (React Query + Axios)\n\nEach API domain in `src/hooks/api/` (100+ domain folders) follows this structure:\n- **`queries.tsx`** — Query key factory pattern: keys return `[{ params }, \"domain-label\"]`. Export named query hooks (`useGetSecrets`, etc.) and raw fetch functions.\n- **`mutations.tsx`** — `useMutation` hooks that invalidate relevant query keys on success via `queryClient.invalidateQueries()`.\n- **`types.tsx`** — Request/response DTOs.\n\nHTTP client in `src/config/request.ts`: Axios instance with automatic token injection and 401/403 interceptors.\n\n#### React Query Global Defaults (`src/hooks/api/reactQuery.tsx`)\n\nThe `QueryClient` sets these global defaults for all queries:\n- **`staleTime: 60_000`** (60 seconds) — data fetched within the last 60s is considered fresh and won't be refetched on component mount/remount. This prevents redundant API calls during normal page navigation. Queries that need real-time data (e.g., identity auth configs, dynamic secret leases) override this with `staleTime: 0`.\n- **`refetchOnWindowFocus: false`** — queries do not refetch when the browser tab regains focus.\n- **`retry: 1`** — failed queries retry once.\n\nMutation failures are reported globally by `MutationCache.onError` using the server error message. Do not add another error `createNotification` in a mutation `catch` or `onError`, because it produces duplicate toasts. Local error handling may still restore optimistic state, keep a dialog open, or perform other control flow without displaying a second notification.\n\nWhen adding new queries, consider whether the default 60s staleTime is appropriate:\n- For data that changes only on explicit user action (secrets, folders, org metadata): the 60s default is fine or could be longer.\n- For data that must always be fresh (auth configs, lease TTLs): override with `staleTime: 0, gcTime: 0`.\n- For rarely-changing data (server config, user profile): use `staleTime: Infinity` as the context providers do.\n\nOrganization switching resets the query cache centrally in `pages/middlewares/inject-org-details.tsx`, after authentication succeeds and before tenant data loads. `hooks/api/organization/cache.ts` tracks the active organization per `QueryClient` and removes all queries except the exact auth-token, user-profile, server-config, and session-announcement keys. Removing queries also cancels pending query results, including queries whose keys omit the organization. Same-organization navigation keeps the cache, and the organization layout remounts on organization changes to reset local UI state. Keep tenant cache cleanup at this boundary rather than adding feature-specific removal lists to individual organization selectors. Logout paths must use `clearSession` to remove user-specific cache entries while preserving public server configuration for the login page.\n\nOrganization switchers use `helpers/organizationSwitch.ts` to retain safe sections and tabs while dropping resource IDs, action parameters, and legacy suborganization search state. Pass the current location through `navigateUserToOrg` only for organization switches; initial login keeps its Projects landing page. Explicit destinations take precedence, and MFA route handoffs retain the requested destination through `redirect_to`.\n\n### Product Analytics\n\nUse `@app/lib/analytics` for new frontend product analytics. Define the event and its typed properties in the shared catalog, then capture it through the method matching its scope so required grouping is applied automatically. Do not call the legacy `Telemetry` wrapper directly from new code or define event names as local strings.\n\nRead [`../ANALYTICS.md`](../ANALYTICS.md) before adding or changing an event. It defines naming, ownership between frontend and backend producers, property cardinality and privacy rules, and lifecycle semantics. Frontend events describe UI exposure and intent; decisive product outcomes belong to backend telemetry so web activity is not double-counted against CLI, machine identity, or other clients.\n\n#### PostHog feature flags and A/B tests\n\n- Put each experiment in `src/components/analytics/experiments/<experiment>/`, with its flag key, typed variants, assignment hook, and focused Vitest coverage kept together. Shared lifecycle code belongs in `src/lib/analytics/experiments/`.\n- Resolve PostHog flags through `resolveFeatureFlagVariant`. It performs a fresh lookup after flags load and owns timeout, error fallback, late-callback suppression, unsubscribe, and unmount cancellation. Set exposure-event behavior deliberately, and persist only values that the experiment resolver recognizes as real assignments.\n- Define the eligible population before initializing PostHog. Excluded flows must not request the flag or emit the experiment conversion. An eligible user who completes after assignment fails can be attributed as `unassigned`; an ineligible user must not be counted.\n- Keep an assignment sticky for the full conversion path, including redirects or region changes. Register dedicated conversion events and typed properties in `@app/lib/analytics`; do not reuse a broader event with different semantics or default unenrolled users into the control cohort.\n- Allow forced variants only in local development or pull-request previews. Production cloud domains must use PostHog assignment, and self-hosted instances must not initialize cloud experiments.\n- Create the PostHog flag and experiment as inactive drafts. Validate variant keys, exposure, conversion events, and metrics against a preview deployment before anyone launches the experiment separately from the code rollout.\n\n### State Management\n\n- **Server state**: TanStack React Query (query key factories in each API domain)\n- **Global app state**: React Context providers in `src/context/` — User, Organization, Project, OrgPermission, ProjectPermission, ServerConfig, Subscription\n- **Local component state**: Zustand stores\n\n### Permissions\n\nCASL-based (`@casl/ability`). Contexts: `OrgPermissionContext` and `ProjectPermissionContext` in `src/context/`. Access via `useOrgPermission()` / `useProjectPermission()` hooks. HOC gates: `src/hoc/withPermission/` and `withProjectPermission/`.\n\n### Styling\n\nTailwind CSS v4 with PostCSS. Dark theme configured via CSS custom properties in `src/index.css` (`@theme` directive). Custom breakpoint `dashboard: 1100px`. Typography roles: IBM Plex Sans is the default product UI face (`font-sans`); Alliance No. 2 Regular is for headings (`font-alliance`); JetBrains Mono is the functional mono (`font-mono`) for code, values, identifiers, and technical microcopy. See the root `DESIGN.md` for typography roles. Colors use semantic roles from `src/index.css`, including surfaces, content, structure, status, scope, and product/resource accents.\n\n### Layouts\n\n9 layout components in `src/layouts/` — `AdminLayout`, `OrganizationLayout`, `SecretManagerLayout`, `PkiManagerLayout`, `KmsLayout`, `PamLayout`, etc. Layouts handle sidebar navigation and page chrome for their product area.\n\n### Org-scoped products\n\nPAM and Agent Vault live at `/organizations/$orgId/<slug>` over a single implicit project, so their URLs carry no `$projectId`. Their layout's `beforeLoad` publishes `implicitProjectId` and `implicitProductType` into route context, and `useImplicitProjectId()` / `useImplicitProduct()` read them back off the matched routes. **Anything that decides whether a `useProject()` caller renders must use `useImplicitProduct()`, never the pathname.** The router changes location before it swaps matches, so a pathname gate mounts a frame early on the way in and unmounts a frame late on the way out, leaving the component asking for a project the router no longer resolves — which throws into `ErrorPage`. Reading the matches keeps these products in step with `useParams`, which is match-derived and is what every other product's gate already uses. The gates that matter today are `OrgSidebar`'s `isInsideProject`, the `ProjectSelect` wrapper, and `OrganizationCommandMenu` in `RootCommandMenu.tsx`, which had exactly this bug when it arrived from main with a pathname check; `getOrgScopedProductFromPath` remains correct for the cosmetic uses behind no `useProject` (navbar scope, `useScopeVariant`, `TypeSelect`).\n\nA new org-scoped product therefore needs: a `<product>ProjectId` on the `Organization` type, an entry in `ORG_SCOPED_PRODUCT_TYPES` and `getOrgScopedProductFromPath` (`helpers/project.ts`), and a layout `beforeLoad` that bootstraps the id, patches it into the cached org, and returns both `implicitProjectId` and `implicitProductType` (see `pages/agent-vault/layout.tsx`). Use `isOrgScopedProduct(type)` rather than adding another pathname check; the sidebar, navbar, project switcher and nav links all key off it.\n\nCertificate Manager is org-scoped too (`/organizations/$orgId/cert-manager/...`), but legacy orgs can hold several instances, so its layout resolves the project from instance state instead of an org field, through `resolveCertManagerProjectId` (`helpers/certManagerActiveProject.ts`): the `infisical-cm-active-project-<orgId>` cookie while the user is a member of that instance, else the active instance. The product switcher and the 403 page use the same helper. The layout writes the resolved id back to the cookie, because `inject-cert-manager-project-id.ts` resolves requests that name no project from it, and reloads the page when the resolved instance changes within one page load, since several cert-manager queries are not keyed by project. Consequences: anything that sends someone into a *specific* instance (the instance picker, a project-scoped identity row, a gateway's discovery row) sets that cookie first, since the URL can't say which; old `/projects/cert-manager/$projectId/...` URLs (emails and alerts sent before the move, bookmarks; the backend now links to `/cert-manager/...`, except legacy PKI Alerts V2 and the legacy-deprecation email) go through `redirects/cert-manager-project-url-redirect.tsx`, which pins the named instance and strips the id. In multi-instance orgs each tab also tags its Certificate Manager API requests with the instance it shows (`setTabCertManagerProjectId`, read by the `apiRequest` interceptor), because the cookie is shared across tabs; single-instance orgs send no project id. Shared views that pick project vs org scope from the URL use `useRouteProjectId()`, which also returns Certificate Manager's implicit project.\n\n### Single-project products\n\nSecret Scanning has one active project per org (see the root `CLAUDE.md`). Members are sent straight into it, and UI for orgs with older projects is gated on `isMultiInstance` from `useGetSecretScanningInstanceState`.\n\n## Conventions\n\n- ESLint flat config (ESLint 9+) with airbnb-typescript + prettier. Double quotes enforced.\n- Import ordering via `simple-import-sort`: node builtins → react/external packages → `@app/` → internal → relative → styles.\n- Forms use `react-hook-form` with `@hookform/resolvers` (Zod schemas).\n- Search params validated with `zodValidator()` from `@tanstack/zod-adapter`.\n- Toasts: call `createNotification({ title?, text, type, callToAction?, copyActions? })` from `@app/components/notifications`. Backed by **sonner** (the v3 `Toaster` in `components/v3/generic/Toast`), mounted once via `NotificationContainer` in `pages/root.tsx`. `react-toastify` has been removed, so do not reintroduce it.\n- Unsaved changes: use `useDiscardChangesGuard` with `DiscardChangesAlertDialog` when dismissing an overlay editor would remove unsaved form changes. Wire dismiss/close to `requestDiscard`. Do not use `window.confirm`. Do not use `useNavigationBlocker` — that hook is secrets batch-mode only.\n\n<!-- File: tests/CLAUDE.md (Tokens: ~902 | Category: claude-rule) -->\n# CLAUDE.md\n\nThe Go blackbox suite. It boots a real Infisical in Docker and calls it over HTTP only.\nIts contract with the server is a Docker image, environment variables, and the OpenAPI\nspec, so the same tests run against any implementation of that contract.\n\n## Read next\n\n| Task | Read |\n|---|---|\n| Writing or reviewing a test | [docs/writing-tests.md](docs/writing-tests.md) |\n| Using or adding a fake third party, App Connection, or mail | [docs/fakes.md](docs/fakes.md) |\n| Calling an endpoint the client does not have yet | [docs/client.md](docs/client.md) |\n| A failing, flaky, or CI-only test | [docs/debugging.md](docs/debugging.md) |\n\n## What belongs here\n\nAnything a customer or API client can observe: responses, status codes, error messages,\nmail, calls to third parties, and effects of queues or crons after the response.\n\nNot here:\n\n- Pure functions and arithmetic. Unit test them in `backend/`.\n- Anything needing direct access to Infisical's own Postgres or Redis, an internal\n  service handle, or a mocked module. Those belong in `backend/e2e-test/`.\n- Exhaustive input validation. Cover representative cases here; matrices go in unit\n  tests.\n\n## Commands\n\n```bash\nmake test-suites   # product tests; starts and stops the stack\nmake test-harness  # harness tests\nmake test-all      # both\nmake test-unit     # no containers\nmake lint          # gofmt, vet, staticcheck\n```\n\nTo iterate on one package, keep the stack up:\n\n```bash\nmake up\ngo test ./suites/secretmanager/secrets/... -count=1\nmake down\n```\n\n`-count=1` disables Go's result cache. Run `make down` after changing a container's\nconfiguration, or the old container is reused.\n\n## Layout\n\n```\nsuites/             product behaviour, grouped by product (suites/secretmanager/secrets)\nharnesstest/        harness behaviour; only for failures the harness alone can cause\nharness/            Stack, Profile, Tenant, Principal\nfixture/            platform resources: projects, App Connections\nfixture/<product>/  one package per product: secretmanager, pki, pam\nfixture/sso/        SAML and OIDC logins, for any suite whose claim needs an SSO session\nfakes/              one package per external provider\ninternal/           wait, mail, id, apierr, spec\nclients/api/        generated client; never edit by hand\n```\n\n- Platform fixtures live in `fixture`; each product's fixtures live in\n  `fixture/<product>`. A product owns its nouns: a secret in Secret Manager is not a\n  secret in Cert Manager. Within a package, add a file per resource.\n- Helpers start in the test package and move to a fixture on the second caller.\n- Imports go one way: `fixture` may import `harness` and `fakes`; `harness` never imports\n  a fixture; `fakes` import neither.\n\n## Rules\n\n- `t.Parallel()` on every test and subtest under `Shared`; never under `Isolated`.\n- Every test body is `// Setup`, `// Action`, `// Assert`.\n- Assert with `testify/require` only.\n- Every test creates its own tenant.\n- Never build an API client by hand; use a principal's `.API` or `tn.Client`.\n- Never sleep. Wait on the status the product records, then assert the outcome.\n- Test plumbing once, not per provider.\n- A rejection test also checks that nothing changed.\n- Use the actor the claim is about, not `tn.Admin` by default.\n- No package, fixture, or option for a single caller.\n\n## Before you call it done\n\n```bash\nmake lint\nmake test-all\nmake up && go test -p 4 ./suites/... ./harnesstest/... -count=3; make down\n```\n\nThe last command is the flake gate. A failure there is shared state leaking across\ntenants. Then break the new assertion, and what it depends on, and confirm both fail.\n</yakaai_skills>"}